The Book of Pint
Welcome to The Book of Pint, an introductory book about Pint. Pint is a declarative programming language for building blockchain applications. The term declarative means that code written in Pint is focused on logic and not execution. In Pint, you can describe what your program must accomplish rather than describing how to accomplish it. This is in contrast with imperative blockchain languages, like Solidity, which require implementing algorithms in explicit steps.
In particular, Pint is a constraint-based language. A Pint program is essentially a collection of predicates and each predicate is a collection of constraints. Because blockchain applications are all about state transitions, constraints allow you to restrict how the state is allowed to change. As a result, you do not have to explicitly express how to change the state, but only what state changes are allowed.
In general, this book assumes that you’re reading it in sequence from front to back. Later chapters build on concepts in earlier chapters, and earlier chapters might not delve into details on a particular topic but will revisit the topic in a later chapter. The book can also be used as a reference, though it is not entirely comprehensive.
Getting Started
To get you started, this chapter discusses the following:
- Installing Pint
- Writing your first Pint program
Installation
Using Nix (Recommended)
The easiest way to get started with Pint and the Essential ecosystem is to use Nix. You first need to install Nix using:
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
or using one of these other alternatives.
Then, enter a Nix shell as follows:
nix develop github:essential-contributions/essential-integration#dev
This will make all the tools you need available in your terminal.
Installing from Cargo
Dependencies
A prerequisite for installing Pint
from Cargo
is the Rust toolchain. Platform-specific
instructions for installing rustup
can be found here.
Then, install the Rust toolchain with:
$ rustup install stable
The Pint toolchain is built and tested against the latest stable
Rust toolchain
version. Ensure you are using the latest
stable
version of Rust with:
$ rustup update && rustup default stable
Installation Steps
The Pint toolchain can be installed using Cargo
with:
$ cargo install pint-cli
You can update the toolchain with Cargo
using:
$ cargo update pint-cli
Syntax Highlighting
Currently, Pint only has syntax highlighting support in Visual Studio Code. We are, however, in the process of adding support for other editors.
Visual Studio Code
To install the Pint plugin, Search the Visual Studio Code market place for pint syntax
.
Alternatively, use this
link.
Quickstart Guide
Now that you’ve installed Pint, it’s time to write your first Pint contract. The first contract we will implement is a counter.
Creating a Project Directory
You'll start by making a directory to store your Pint code. Open a terminal and enter the following
commands to make a projects
directory:
mkdir ~/projects
cd ~/projects
Now, we will use the pint
tool to create a new project in the projects
directory:
pint new counter
cd counter
The pint new
commands creates a new Pint project with the provided name. In the newly created
directory counter
, you should find a pint.toml
file as well as a src/
directory with a single
file in it called contract.pnt
:
projects
└── counter
├── pint.toml
└── src
└── contract.pnt
The auto-generated pint.toml
file describes how to build the project. The src/contract.pnt
file
is the root file of a contract project, i.e., this is where the compilation starts when we build the
project.
Open the pint.toml
and inspect its content:
[package]
name = "counter"
kind = "contract"
[dependencies]
# Library dependencies go here.
[contract-dependencies]
# Contract dependencies go here.
The one thing to note is that the kind
field in this pint.toml
is set to contract
. This is the
default project kind. Alternatively, kind
can be a library.
Writing a Pint Program
Next, open the src/contract.pnt
file and erase its content. Then, paste the following:
storage {
counter: int,
}
predicate Increment() {
let counter: int = mut storage::counter;
constraint (counter == nil && counter' == 1) || counter' == counter + 1;
}
This is a contract with a single predicate and a single storage variable. The storage variable
counter
, of type int
(i.e. integer), is declared in a storage
block. The only predicate in
this contract is called Increment
and contains two statements:
- A
let
declaration which reads the variablecounter
. - A
constraint
statement which enforces some logic on the state of the contract.
The above constraint is essentially saying: "if counter
hasn't been set yet (i.e. is nil
), set
the new value of counter
to 1
(counter' == 1
). Otherwise, increment counter
by 1
".
Don't worry if this looks a bit overwhelming! We will later dive into each feature in this contract separately.
Building the Project
To build the project above, simply run the following command in the counter
directory:
pint build
You should get something like this:
Compiling counter [contract] (/path/to/counter)
Finished build [debug] in 5.470625ms
contract counter 1899743AA94972DDD137D039C2E670ADA63969ABF93191FA1A4506304D4033A2
└── counter::Increment 355A12DCB600C302FFD5D69C4B7B79E60BA3C72DDA553B7D43F4C36CB7CC0948
Note the two 256-bit numbers in the output. These represent the content hash (i.e. the sha256
of
the bytecode) of the counter
contract (as a whole) and the Increment
predicate, respectively.
These "addresses" will be used when referring to the contract or the predicate (in a proposed
solution for example).
In the counter
directory, you will also notice a new directory called out
. Navigate to
out/debug
and inspect the two json
files that you see.
counter.json
represents the compiled bytecode in JSON format, which is the most important artifact produced by the compiler. This file is used when validating a solution. That is, when a solution is submitted, this file contains the bytecode that "runs" and decides whether all the relevant constraints are valid.counter-abi.json
is the Application Binary Interface (ABI) of the contract. It basically describes how to interact with the contract from an external application or another contract. For example, while crafting a solution, the ABI can be used to figure out where the various storage variables are stored (i.e. their keys) and their types. This information is crucial to form correct solutions.
Note: Appendix C contains the ABI spec.
Now that we have built and inspected the artifacts of our project, we can proceed to build an application that interacts with this contract. We won't cover this topic here, but you can check out this Getting Started with Essential Application book, which covers this exact same "counter" example and how to build a Rust application that interacts with it using the Essential VM.
Example
This chapter includes some basic examples that show how Pint code looks like:
We will go over each example line-by-line while briefly introducing new concepts. Later, however, we will cover each new concept in depth in its own chapter. Therefore, the goal of this chapter is not to teach you everything about Pint but to get you to (hopefully!) appreciate what Pint is capable off.
Counter
The "counter" is one of the simplest smart contracts that can be written in Pint. It showcases how a contract can have multiple predicates and how it can declare and use storage. This particular implementation of the "counter" contract is different from the one we encountered in the Quickstart Guide.
storage {
counter: int,
}
predicate Initialize(value: int) {
let counter: int = mut storage::counter;
constraint counter' == value;
}
predicate Increment(amount: int) {
let counter: int = mut storage::counter;
constraint counter' == counter + amount;
}
The contract starts by declaring a storage
block which contains a single storage variable called
counter
of type int
(i.e. integer). The contract later declares two separate predicates, each
having a single parameter and declaring two statements. Let's walk through the first predicate
named Initialize
:
- Predicate
Initialize
has a single parameter calledvalue
of typeint
. The parameters of a predicate are essentially decision variables that a solver is required to find values for such that everyconstraint
in the predicate evaluates totrue
. The expression "decision variable" is commonly used in constraint programming languages to refer to unknowns that a solver must find given some constraints. InInitialize
, the parametervalue
is the value that we want our counter to get initialized to. - The second statement declares a local variable and initializes it to
mut storage::counter
. The statementlet counter: int = mut storage::counter
creates a local variable calledcounter
and initializes it to the current value ofcounter
declared in thestorage
block. Themut
keyword simply indicates that the solver is allowed to propose a new value forcounter
. Ifmut
is not present, then the storage variablecounter
cannot be modified by anyone attempting to solve predicateInitialize
. - The third statement contains the core logic of this predicate. It declares that the "next
value" of
counter
must be equal tovalue
. Note the'
notation here which can be only applied to a local variable, and means "the next value of the state variable after a valid state transition".
The second predicate, called Increment
, has a similar structure to Initialize
. However, instead
of initializing counter
, It increments it by amount
. Note that both counter
(the current
value) and counter'
(the next value) are both used in the constraint to enforce that the next
value is dependent on the current value, which was not the case in Initialize
.
Solution
We won't go too deep into the solving process here but it's worth mentioning what a solution to this predicate might look like. Broadly speaking, a solution contains two things:
- An assignment of all the parameters of the predicate.
- A list of all the state mutations proposed.
For example, if a user wants the value of the counter to be 42
they (or a solver acting on their
behalf) can propose a solution to Initialize
that looks like this:
# parameters:
value: 42
# state mutations:
0: 42
This solution proposes a value of 42
for parameter value
and a new value of 42
for the storage
key 0
where counter
is stored (we will go over the storage data layout later!). Note that the
storage key 0
where counter
is stored can be modified by the solution because of the mut
keyword added before storage::counter
.
A solution must also indicate which predicate is being solved using its address but we're omitting that here for simplicity.
Alternatively, a solution to Increment
can be proposed to satisfy the user requirement counter = 42
. For example, if the current value of counter
happens to be 35
, then the following solution
to Increment
can be proposed:
# parameters:
amount: 7
# state mutations:
0: 42
It should be easy to verify that this solution satisfies the constraint counter' == counter + amount;
from Increment
.
Subcurrency
The following contract implements the simplest form of a cryptocurrency. The contract allows the creation of new coins (i.e. minting) as well as sending coins from one address to another.
storage {
total_supply: int,
balances: (b256 => int),
}
// Sends an amount of newly created coins to an address
predicate Mint(receiver: b256, amount: int) {
let receiver_balance = mut storage::balances[receiver];
let total_supply = mut storage::total_supply;
constraint total_supply' == total_supply + amount;
constraint receiver_balance' == receiver_balance + amount;
}
// Sends an amount of existing coins from address `from` to address `receiver`
predicate Send(from: b256, receiver: b256, amount: int) {
let from_balance = mut storage::balances[from];
let receiver_balance = mut storage::balances[receiver];
constraint amount < from_balance;
constraint from_balance' == from_balance - amount;
constraint receiver_balance' == receiver_balance + amount;
}
This contract introduces some new concepts. Let's walk through it line by line.
The contract starts with a storage
declaration that contains two storage variables:
total_supply
is of typeint
and represents the total supply of coins available at any given point in time.balances
is a map fromb256
toint
and stores balances of addresses as integers.b256
is a primitive type that represents a 256-bit hash value and is used here to represent an address.
The contract also declares two predicates: Mint
and Send
.
The Mint
predicate is fairly simple:
- It has two parameters
receiver: b256
andamount: int
. The goal of this predicate to mintamount
coins and send them toreceiver
. - It initializes a local variable called
receiver_balance
using the storage access expressionmut storage::balances[receiver]
. This syntax returns the value inbalances
thatreceiver
maps to and makes it "mutable". The predicate also initializes another local variable calledtotal_supply
tomut storage::total_supply
. - It enforces two constraints:
- The first constraint requires the total supply to be incremented by
amount
. - The second constraint requires the balance of
receiver
to be incremented byamount
.
- The first constraint requires the total supply to be incremented by
The Send
predicate has the following structure:
- It has three parameters
from: b256
,receiver: b256
, andamount: int
. The goal of this predicate to sendamount
coins from addressfrom
to addressreceiver
. - It initializes a local variable called
from_balance
to the balance offrom
and another variable calledreceiver_balance
to the balance ofreceiver
. It also makes both balances "mutable". - It enforces three constraints
- The first constraint requires
from_balance
to be larger thanamount
. That is, the addressfrom
must currently actually have enough coins to send toreceiver
. - The second constraint effectively decrements the balance of
from
byamount
, by requiring the next state offrom_balance
to befrom_balance - amount
. - The third constraint effectively increments the balance of
receiver
byamount
, by requiring the next state ofreceiver_balance
to bereceiver_balance + amount
.
- The first constraint requires
Note to make things simpler and easier to understand, this contract has no authentication anywhere in its code. That is, anyone can mint new coins and initiate a transfer from one arbitrary address to another. This, of course, is not the desired behavior for most cryptocurrencies. That being said, examples of authentication can be found in this Github repository.
Smart Contracts
Pint is a language for writing "smart contracts". If you're familiar with smart contract languages like Solidity, then many elements of a Pint contract should feel familiar. Of course, at its core, Pint is fundamentally different from imperative smart contract languages. Writing smart contracts in Pint requires a different way of thinking about how to express the rules that the smart contract must enforce.
A Pint contract is a collection of predicates. Each predicate has a name, a list of typed
parameters, and a list of constraints. Predicates of a smart contract describe the various ways
state can be mutated in order to accomplish certain tasks (e.g. token transfer). A contract may
also contain a storage
declaration which contain all the storage variable that the contract owns.
Contract storage is effectively the database of the contract where persistent state lives. We will
discuss storage in details in Chapter 5.
Contract Structure
The structure of a Pint smart contract is fairly simple. We simply lay out all the code blocks as follows:
storage {
// storage variables
}
predicate Foo(
// parameters
) {
// local variables, constraints, etc.
}
predicate Bar(
// parameters
) {
// local variables, constraints, etc.
}
// other predicates
The order of the code blocks is not important, and the storage
block is optional but most useful
smart contracts will need it.
Unlike imperative smart contracts where the logic lives in contract methods that can be called to make state updates, Pint contracts have predicates (not methods/functions even though they look like ones!) and nothing is ever "called". Instead, solutions have to submitted that satisfy one or more predicates in the contract. A solution must specify concrete values for the parameters of the solved predicates, as well as propose changes to the state if necessary.
If the proposed values for the parameters and the proposed state changes satisfy all the constraints for each solved predicate (potentially including predicates from other contracts), then the solution is deemed valid and the proposed state changes should be accepted.
Predicate Parameters
A predicate parameter is a named parameter that every solution is required to assign a value for. Predicate parameters are quite different from "regular" function parameters that you might be used to in imperative programming languages since predicates of a smart contract are not called!. Instead, they are solved by proposing values for these parameters such that all the constraints are satisfied.
All predicate parameters have to be annotated with a type. We will go over the available data types in Pint, in detail, in a later chapter.
Here's an example that shows how to declare a predicate named test
with three parameters foo
,
bar
, and baz
with three different types:
predicate test(
foo: int,
bar: bool,
baz: b256,
) {
constraint foo * foo <= 1024;
// other constraints
}
The predicate test
also declares a constraint that enforces that the square of foo
is at most
1024
, meaning that any proposed solution must assign a value for foo
that satisfies this
condition. For example, if foo
is set to 7
, this is constraint would be satisfied. If foo
is
set to 42
, this constraint would not be satisfied. We will go over constraints in more detail in
Chapter 4.6.
You can think of the type annotation on each predicate parameter as an implicit "constraint". For
example, parameter foo
can only take values in the set of signed integers (64-bit signed integers
when targeting the EssentialVM) while bar
can only take two values: false
or true
(i.e. 0
or
1
in the EssentialVM).
Contract Interfaces
Each smart contract has an interface which can be easily generated from the contract. The interface is not required to write the smart contract but is required to interact with the contract from external contexts. For example, one smart contract can propose an update to a storage variables that is owned by another contract. Will will go over how to do that in Chapter 5. Another example is invoking external predicates which is a more advanced topic that we will cover in Chapter 7.1.
A contract interface has the following structure:
interface ExternalContract {
storage {
// storage variables
}
predicate Foo(
// parameters
);
predicate Bar(
// parameters
);
// other predicate interfaces
}
You can see the similarities between the structure of the interface and the structure of the smart
contract. An interface starts with interface
keyword followed by the name of the interface which
can be used when referring the interface from external contexts. Inside the interface declaration,
an optional storage
block can be declared as well as a list of predicate interfaces, each with
its own list of typed parameters. The storage block and the predicate signatures of an interface
should always match the corresponding storage block and predicate signatures of the deployed
contract. Otherwise, correct behavior cannot be guaranteed.
For example, an interface for the counter example looks like this:
interface Counter {
storage {
counter: int,
}
predicate Initialize(value: int);
predicate Increment(amount: int);
}
Hopefully nothing here is surprising! The key point is that an interface
must expose everything
that is public in a contract, and that includes the storage block and the predicate signatures.
Note in the future, Pint will have a tool that will auto-generate interfaces from a smat contract.
Common Programming Concepts
This chapter covers concepts that appear in almost every programming language and how they work in Pint. Many programming languages have much in common at their core. None of the concepts presented in this chapter are unique to Pint, but we’ll discuss them in the context of Pint and particularly how they work in a constraint-based environment as opposed to imperative environments that you might be more familiar with.
Specifically, you’ll learn about local variables, basic types, custom types, match
constructs,
comments, and conditionals. These foundations will be in every Pint program, and learning them early
will give you a strong core to start from.
Keywords: The Pint language has a set of keywords that are reserved for use by the language only, much as in other languages. Keep in mind that you cannot use these words as names of variables, predicates, types, or macros. Most of the keywords have special meanings, and you’ll be using them to do various tasks in your Pint programs; You can find a list of the keywords in Appendix A.
Local Variables
Local variables in Pint are similar to local variables in other languages with a few restrictions. In Pint, variables must be initialized and are immutable. They basically hold values and help you write readable code, but are not meant to be modified. This is an important property that allows Pint to be fully declarative with no control flow. In Pint, the order in which you write your statements (variable declarations, constraints, etc.) is unimportant and has no impact on the behavior of the code.
In order to declare a new variable, we use the let
keyword:
predicate foo() {
let x = 5;
let y: int = 6;
constraint y - x == 1;
}
The first let
declares a variable named x
and assigns its value to 5
. The second let
declares a variable named y
and assigns its value to 6
. The constraint y - x == 1
references
x
and y
using their names and is obviously always true
in this case since 6 - 5 == 1
.
Note that, while y
is annotated with type int
, we opted to omit a type annotation for x
; we're
relying on the compiler to infer its type to be int
since the initializing expression is 5
which is an int
.
If we were to declare y
as follows:
let y: int = true;
then the compiler would emit an error because the type annotation int
and the type of the
initializing expressions true
do not match:
Error: variable initialization type error
╭─[variables.pnt:4:18]
│
4 │ let y: int = true;
│ ─┬─ ──┬─
│ ╰────────── expecting type `int`
│ │
│ ╰─── initializing expression has unexpected type `bool`
───╯
Shadowing
Name shadowing is not allowed in Pint. Declaring two variables with the same name will result in a compiler error. For example, the following code fails to compile:
let y = 5;
let y: int = 6;
and the following error is emitted:
Error: symbol `y` has already been declared
╭─[variables.pnt:4:9]
│
3 │ let y = 5;
│ ────┬────
│ ╰────── previous declaration of the symbol `y` here
4 │ let y: int = 6;
│ ┬
│ ╰── `y` redeclared here
│
│ Note: `y` must be declared or imported only once in this scope
───╯
Data Types
Every value in Pint is of a certain data type, which tells Pint and solvers what kind of data is being specified so they know how to work with that data. We'll look at two data type subsets: scalar and compound.
Keep in mind that Pint is a statically typed language, which means that it must know the types of all variables at compile time. The compiler can often infer what type we want to use based on the value and how we use it. In cases where many types are possible, we must add a type annotation as described in the chapter on local variables.
Scalar Types
A scalar type represents a single value. Pint has three primary scalar types: integers, Booleans, and 256-bit hashes.
Integer Type
An integer is a number without a fractional component. Pint has a single integer type called int
which, when targeting the EssentialVM, represents a 64-bit signed integer . An int
, therefore,
can store numbers from to inclusive, where n
is the number of bits that
represent the integer (64 in the case of EssentialVM).
You can write integer literals in any of the forms shown in the table below. Note that number
literals can use _
as a visual separator to make the number easier to read, such as 1_000
, which
will have the same value as if you had specified 1000
.
Number literals | Examples |
---|---|
Decimal | 12_333 |
Hex | 0x123f |
Binary | 0b1111_1101 |
Numeric Operations
Pint supports the basic mathematical operations you’d expect for integers: addition, subtraction,
multiplication, division, and remainder. Integer division truncates toward zero to the nearest
integer. The following code shows how you’d use each numeric operation in a let
statement:
// addition
let sum = 1 + 2 + 3;
// subtraction
let difference = 15 - 1;
// multiplication
let product = 42 * 42;
// division
let quotient = 3 / 2;
let truncated = -5 / 3; // Results is -1
// remainder
let remainder = 34 % 3;
Note that binary operators in Pint are strict with regards to what types they allow, that is, only
identical types can be used in a binary operator. For example, adding an int
and a bool
is not a
valid operation and will result in a compile error.
The Boolean Type
As in most other programming languages, a Boolean type in Pint has two possible values: true
and
false
. The Boolean type in Pint is specified using bool
. For example:
let t = true;
let f: bool = false;
The b256
Type
The b256
is a special type that represents a 256-bit hash. It is often used to represent addresses
or storage keys and cannot be used as a numerical integers. That is, two b256
values cannot added
for example.
A b256
literals can be represented using a Hexadecimal or a Binary literal as follows:
let addr1 = 0x3333333333333333333333333333333333333333333333333333333333333333;
let addr2: b256 = 0b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111;
Compound Types
Compound types can group multiple values into one type. Pint has two primitive compound types: tuples and arrays.
The Tuple Type
A tuple is a general way of grouping together a number of values with a variety of types into one compound type. Tuples have a fixed length: once declared, they cannot grow or shrink in size.
We create a tuple by writing a comma-separated list of values inside curly brackets { .. }
. Each
position in the tuple has a type, and the types of the different values in the tuple don’t have to
be the same. We’ve added optional type annotations in this example:
let tup_1: { int, int, bool } = { 42, 4, true };
The variable tup
binds to the entire tuple because a tuple is considered to be a single compound
element. To get the individual values out of a tuple, we can use the period (.
) operator followed
by the index of the value we want to access. For example:
let tup_2: { int, int, bool } = { 42, 4, true };
let tup_2_first = tup_2.0;
let tup_2_second = tup_2.1;
let tup_2_third = tup_2.2;
This program creates the tuple tup_2
and then accesses each element of the tuple using its
respective index. As with most programming languages, the first index in a tuple is 0.
It is also possible to name some or all the fields of a tuple type as follows:
let tup_3: { x: int, int, y: bool } = { 42, 4, true };
Note that, in this example, only two out of the 3 fields are named. In order to access the
individual elements of a tuple with named fields, the period (.
) can again be used with either the
index of the tuple field or its name. For example:
let tup_4: { x: int, int, y: bool } = { 42, 4, true };
let tup_4_first = tup_4.0;
let tup_4_first_named = tup_4.x; // same as `tup_4.0`
let tup_4_second = tup_4.1;
let tup_4_third = tup_4.2;
let tup_4_third_named = tup_4.y; // same as `tup_4.y`
Tuples without any values are not allowed in Pint
. That is, the following:
let empty: {} = {};
is disallowed and errors out as follows:
Error: empty tuple types are not allowed
╭─[data_types.pnt:43:12]
│
43 │ let empty: {} = {};
│ ─┬
│ ╰── empty tuple type found
────╯
Error: empty tuple expressions are not allowed
╭─[data_types.pnt:43:17]
│
43 │ let empty: {} = {};
│ ─┬
│ ╰── empty tuple expression found
────╯
The Array Type
Another way to have a collection of multiple values is with an array. Unlike a tuple, every element of an array must have the same type. Unlike arrays in some other languages, arrays in Pint have a fixed length.
We write the values in an array as a comma-separated list inside square brackets:
let a = [1, 2, 3, 4, 5];
You write an array's type using the element type followed by its size between square brackets, like so:
let b: int[5] = [1, 2, 3, 4, 5];
Here, int
is the type of each element. The number 5
indicates that the array contains five
elements.
You can access elements of an array using by indexing into it, like this:
let c: int[5] = [1, 2, 3, 4, 5];
let c_first = c[0];
let c_second = c[1];
In this example, the variable named c_first
will get the value 1
because that is the value at
index 0
in the array. The variable named c_second
will get the value 2
from index 1
in the
array.
Comments
All programmers strive to make their code easy to understand, but sometimes extra explanation is warranted. In these cases, programmers leave comments in their source code that the compiler will ignore but people reading the source code may find useful.
Here’s a simple comment:
// hello, world
In Pint, the only comment style supported starts a comment with two slashes, and the comment
continues until the end of the line. For comments that extend beyond a single line, you’ll need to
include //
on each line, like this:
// This is some complicated code that requires multiple lines to explain:
// 1. The first predicate, called `GetRich` ensures that we're getting rich.
// 2. The second predicate, called `BeResponsible` ensures that
// we're not gambling all the money away.
Comments can also be placed at the end of lines containing code:
let big_answer = 42; // answer to life, the universe, and everything
But you’ll more often see them used in this format, with the comment on a separate line above the code it’s annotating:
// answer to life, the universe, and everything
let big_answer_too = 42;
Conditionals
Pint has three different ways to express conditionals: select expressions, cond
expressions, and
if
statements. Conditionals allow you to "branch" your code depending on some condition(s). The
word "branch" is between quotations because Pint is not an imperative language and does not
"execute" code. Therefore, conditionals are simply a way of saying: "choose between some expressions
or some constraints based on some condition(s)".
Pint also has match
statements that are useful to reason about unions. Both unions and match
will be discussed separately in Chapter 3.5
Select Expressions
A select expression allows you to select between two alternatives based on a condition. The two alternatives must have the same type and that type determines the type of the whole select expression. For example:
let y = (number < 5 ? 1 : 2);
All select expressions start with a condition followed by the ?
symbol. In this case, the
condition checks whether or not the local variable number
has a value less than 5. We place the
expression that should be chosen if the condition is true
immediately after the ?
symbol. The
symbol :
is then added followed by the expression that should be chosen if the condition is
false
. Both options, 1
and 2
, have the same type which is int
and so, the type of y
must
also be int
.
If, for example, the types of the two expressions we're selecting from do not match, the compiler will emit a compile error. For example, if we try to compile the following code:
predicate test(number: int) {
let y = number < 5 ? 1 : true;
}
we will get the following error:
Error: branches of a select expression must have the same type
╭─[test.pnt:2:13]
│
2 │ let y = number < 5 ? 1 : true;
│ ┬ ──┬─
│ ╰───────── 'then' branch has the type `int`
│ │
│ ╰─── 'else' branch has the type `bool`
───╯
The condition of a select expression must be a bool
. Otherwise, we will get a compile error. For
example, if we try to compile the following code:
predicate test(number: int) {
let y = number ? 1 : 2;
}
we will get the following error:
Error: condition for select expression must be a `bool`
╭─[test.pnt:2:13]
│
2 │ let y = number ? 1 : 2;
│ ───┬──
│ ╰──── invalid type `int`, expecting `bool`
───╯
Note that Pint will not automatically try to convert non-Boolean types to a Boolean. You must be
explicit and always provide a select expression with a Boolean
as its condition.
cond
Expressions
Pint provides cond
expressions. cond
expressions are generalized select expressions that are not
limited to only two branches. They provide selection from multiple alternatives, each based on some
condition. For example:
let z = cond {
x == 0 => 0,
x > 0 && x <= 10 => 1,
x > 10 && x <= 100 => 2,
else => 3
};
All cond
expressions start with the keyword cond
, followed by a comma-separated list of
statements in between curly brackets. Each statement describes a condition and an expression that
should be returned by the cond
if that condition is correct. The branches are evaluated in order
and the first one to become active determines the value of the cond
expression. If all branches
fail, then the cond
expression takes the value of the expression in the else
branch, which must
always be the last branch.
in the example above, z
is equal to 0
if x == 0
, equal to 1
if x
is between 0
and 10
,
equal to 2
if x
is between 10
and 100
, and equal to 3
otherwise.
every cond
expression can be rewritten using one or more select expressions. however, cond
tends
to be more compact and more readable than nested select expressions. for example, the cond
expression in the example above is equivalent to:
let z = x == 0 ? 0 :
x > 0 && x <= 10 ? 1
: x > 10 && x <= 100 ? 2 : 3;
similarly to select expressions, all candidate expressions must have the same type which determines
the type of the whole cond
expression. also, every condition must be a bool
or else a compile
error will be emitted.
if
Statements
if
statements are the last class of conditionals we will look at. Unlike select and cond
expressions, if
statements are not expressions, that is, they do not hold any values. Instead,
they allow predicating blocks of code based on some condition. A block of code in the context of
an if
statement is a collection of constraints and other if
statements. For example:
if number < 5 {
constraint x == y;
} else {
constraint x != y;
}
All if
statements start with the keyword if
, followed by a condition. In this case, the
condition checks whether or not the local variable number
has a value less than 5. The code block
that should be "active" if the condition is true
is placed immediate after the condition inside
curly brackets. Optionally, the keyword else
is then added followed by the code block that should
be active if the condition is false
(also between curly brackets). In the example above, the if
statement can be read as follows: "if number
is less than 5, then x
must be equal to y
.
Otherwise, x
must not be equal to y
".
Similarly to select expressions, the condition of an if
statement must be a bool
. Otherwise we
will get a compile error.
if
statements can be nested and can contain an arbitrary number of constraints:
if number < 5 {
constraint x == y;
if z > 0 {
constraint x > number;
constraint y > number;
}
} else {
constraint x != y;
if z > 0 {
constraint x > number;
constraint y > number;
} else {
constraint x < number;
constraint y < number;
}
}
Custom Types
Custom data types are named types that you can define in your program to refer, via an alias, to a
primitive type, a compound type, or a union
. Unions are another special class of custom types that
allow you to define a type by enumerating its possible variants.
Both type aliases and union declarations must be made at the top level of a module, and are not
allowed inside a predicate
declaration.
This chapter covers type aliases and unions. It also covers match
expressions and match
statements which help reason about unions and their variants.
Type Aliases
Pint provides the ability to declare a type alias to give an existing type another name. For this we
use the type
keyword. For example, we can create the alias Balance
to int
like so:
type Balance = int;
Now, the alias Balance
is a synonym for int
. Values that have the type Balance
will be treated
the same as values of type int
:
let x: int = 5;
let y: Balance = 5;
constraint x == y;
Because Balance
and int
are the same type, we can compare values of both types.
Defining structs using type
Many programming languages offer the concept of a "struct" which lets you package together and
name multiple related values that make up a meaningful group. While Pint does not offer a
special struct
construct, it does offer a way to name a tuple, name its fields, and access its
elements as if it were a struct
.
To define a struct-like tuple (which we will just call a struct going forward), we use the type
keyword followed by the name chosen for the tuple. We then use the =
operator to bind the new type
name to a tuple type with all of its fields named.
For example:
type User = {
status: bool,
address: b256,
balance: int,
};
To use a struct after we’ve defined it, we create an instance of that struct by specifying concrete
values for each of the fields. We create an instance using the same tuple expression syntax: curly
brackets containing key: value pairs, where the keys are the names of the fields and the values
are the data we want to store in those fields. We don’t have to specify the fields in the same order
in which we declared them in the struct. In other words, the struct definition is like a general
template for the type, and instances fill in that template with particular data to create values of
the type. For example, we can declare a particular User
as shown below:
let user1: User = {
status: true,
address: 0x1111111111111111111111111111111111111111111111111111111111111111,
balance: 42,
};
To get a specific value from a struct, we use the dot notation similarly to tuples. For example, to
access this user's balance, we use user1.balance
.
Unions
Unions allow you to define a type by enumerating its possible variants. Where structs and tuples
give you a way of grouping together related fields and data, like a User
with its status
,
address
, and balance
, unions give you a way of saying a value is one of possible set of values.
For example, we may want to say that User
is one of a set of possible account types that also
includes Contract
. To do this, Pint allows us to encode these possibilities as a union
.
Let’s look at a situation we might want to express in code and see why unions are useful and more appropriate that structs in this case. Say we need to describe token ownership of one of three available tokens on an exchange: DAI, USDC, and USDT. Because these are the only tokens we can work with, we can enumerate them all as different variants:
union Token = DAI | USDC | USDT;
Note how the possible variants of Token
are separated by a |
. Token
is now a custom data type
that we can use elsewhere in our code. Any Token
can now be DAI
, USDC
, or USDT
but never 2
or more of these at the same time. That property of Token
makes the union data structure
appropriate because a union variant can only be one of its variants. All three tokens are still
fundamentally tokens, so they should be treated as the same type when the code is handling
situations that apply to any of these tokens.
Union Values
We can now create an instance of each of the three variants of Token
like this:
let dai: Token = Token::DAI;
let usdc: Token = Token::USDC;
let usdt: Token = Token::USDT;
Note that the variants of the union are namespaced under its identifier, and we use a double colon
to separate the two. This is useful because now all three values Token::DAI
, Token::USDC
, and
Token::USDT
are of the same type: Token
. We can then, for instance, declare a variable called
token_type
to be of type Token
and assign it to either variants depending some token ID:
let token_type: Token = cond {
token_id == 0 => Token::DAI,
token_id == 1 => Token::USDC,
else => Token::USDT,
};
Using unions has even more advantages. Thinking more about our Token
type, at the moment we don’t
have a way to store the actual token amount; we only know what kind it is. Given what you know about
structs and tuples, you might be tempted to tackle this problem with structs as follows:
type Balance = {
token: Token,
balance: int,
};
predicate balances_1() {
let alice_bal: Balance = {
token: Token::DAI,
balance: 42,
};
let bob_bal: Balance = {
token: Token::USDC,
balance: 96,
};
}
Here, we've defined a new type called Balance
that has two fields: a token
field that is of type
Token
(the union we defined previously) and a balance
field of type int
that represents the
balance. We have two instances of this type. The first is alice_bal
, and it has the value
Token::DAI
as its token
with associated balance
of 42
. The second instance is bob_bal
. It
has another variant of Token
as its kind
value, Token::USDC
, and has a balance
of 96
associated with it. We've used a struct to bundle the token
and balance
values together, so now
the variant is associated with the value.
However, representing the same concept using just a union is more concise: rather than a union
inside a struct, we can put data directly into each union variant. This new definition of the
Token
union says that the variants DAI
, USDC
, and USDT
will have associated int
values:
union TokenBalance = DAI(int) | USDC(int) | USDT(int);
predicate balances_2() {
let alice_bal: TokenBalance = TokenBalance::DAI(42);
let bob_bal: TokenBalance = TokenBalance::USDC(96);
}
We attach data to each variant of the union directly, so there is no need for an extra struct. Also notice the syntax for constructing an instance of the union where the value held by a variant is added between parentheses after the variant name.
There's another advantage to using a union rather than a struct: each variant can have different types and amounts of associated data. Let's look at another example of a union that has a wide variety of types embedded in its variants:
union Action = Quit // Quit the application
| Buy (TokenBalance) // Buy some amount of a token
| Sell({TokenBalance, price: int}) // sell some amount of a token at some price
| Swap({TokenBalance, TokenBalance}); // Swap some amount of a token for another amount of some other token
This union has four variants with different types:
Quit
has no data associated with it at all.Buy
includes aTokenBalance
.Sell
andSwap
includes tuples.
Defining a union with variants such as the ones above is similar to defining different kinds of
struct definitions, except the union variants are grouped together under the Action
type which
makes it easier to reason about them as part of a single type.
The match
Construct
Pint has the powerful construct match
that allows you to inspect a value that has a union type and
conditionally execute some code based on which union variant that union value matches. The power of
match
comes from the introduction of the union variant datum into a specific scope, and the fact
that the compiler confirms that all possible variants are handled.
Pint has both match
expressions and match
statements, the distinction between which is
illustrated below.
match
as an Expression
Consider the following example which takes an unknown US coin and determines which coin it is and returns its value in cents:
union Coin = Penny | Nickel | Dime | Quarter;
predicate CoinConversion1(coin: Coin) {
let coin_in_cents = match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
};
}
Let’s break down the match
expression above. First we list the match
keyword followed by an
expression, which in this case is the value coin
. This seems very similar to a conditional
expression used with cond
, but there’s a big difference: with cond
, the condition needs to
evaluate to a Boolean value, but here it has to be a union variant of Coin
because this is the
type of the value coin
.
Next are the match
arms. An arm has two parts: a pattern and some code. The first arm here has a
pattern that is the value Coin::Penny
and then the =>
operator that separates the pattern and
the code to run. The code in this case is just the value 1
. Each arm is separated from the next
with a comma.
In this particular example, the code associated with each arm is an expression, and the resultant
value of the expression in the matching arm is the value that gets returned for the entire match
expression.
It is also possible to include constraints
in the match arm code, but you must use curly brackets.
For example, the following code requires the Boolean variable is_lucky_penny
to be true
if the
value coin
is a Coin::Penny
.
predicate CoinConversion2(coin: Coin, is_lucky_penny: bool) {
let coin_in_cents = match coin {
Coin::Penny => {
constraint is_lucky_penny;
1
},
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
};
}
Patterns that Bind to Values
Another useful feature of match arms is that they can bind to the value that match the pattern. This is how we can extract values out of union variants.
As an example, let's change our union variants to hold data inside them. Namely, we want each
variant to hold an int
that represents the number of coins available:
union Coins = Penny(int) | Nickel(int) | Dime(int) | Quarter(int);
Now, given a value coins
of type Coins
, we can compute the total number of cents that coins
is
equivalent to as follows:
predicate CoinConversion3(coins: Coins) {
let coins_in_cents = match coins {
Coins::Penny(n) => n,
Coins::Nickel(n) => n * 5,
Coins::Dime(n) => n * 10,
Coins::Quarter(n) => n * 25,
};
}
In the match
expression above, we add a variable called n
for each of the patterns. In each
pattern, n
will bind to the value that the union variant holds. For example, if coins
is equal
to Coin::Nickel(42)
, then we expect coins_in_cents
to be equal to 42 * 5 == 210
.
Note that match
expressions can be nested. Here's an example that has nested unions and nested
match
expressions to compute a prize
given a coin and the face it landed on in a head-or-tails
game.
union Face = Head | Tail;
union CoinFace = Penny(Face)
| Nickel(Face)
| Dime (Face)
| Quarter(Face);
predicate CoinGame1(coin: CoinFace) {
let prize = match coin {
CoinFace::Penny(f) => match f {
Face::Head => 100,
Face::Tail => 200,
},
CoinFace::Nickel(f) => 5 * match f {
Face::Head => 10,
Face::Tail => 20,
},
CoinFace::Dime(f) => 10 * match f {
Face::Head => 5,
Face::Tail => 10,
},
CoinFace::Quarter(f) => 25 * match f {
Face::Head => 1,
Face::Tail => 2,
},
};
}
match
as a Statement
In some cases, you may not need a match
to return a value. Instead, you may simply want to enforce
conditional constraints based on which pattern matches the given value. Below is a rewrite of the
previous example that uses match
statements. The code exhibits the exact same behavior as before
but written different to showcase match
statements.
predicate CoinGame2(coin: CoinFace, prize: int) {
match coin {
CoinFace::Penny(f) => {
match f {
Face::Head => {
constraint prize == 100;
}
Face::Tail => {
constraint prize == 200;
}
}
}
CoinFace::Nickel(f) => {
if f == Face::Head {
constraint prize == 5 * 10;
} else {
constraint prize == 5 * 20;
}
}
CoinFace::Dime(f) => {
constraint f == Face::Head ? 10 * 5 : 10 * 10;
}
CoinFace::Quarter(f) => {
constraint prize == 25 * match f {
Face::Head => 1,
Face::Tail => 2,
};
}
}
}
Here, the top level match
is a statement, not an expression; it does not return any value.
Instead, it declares some constraints, each based on one or more conditions. .
- If
coin
matchesCoinFace::Penny(f)
, then we add anothermatch
statement that includes different constraints based on what patternf
matches. - If
coin
matchesCoinFace::Nickel(f)
, then we add anif
statement that also includes different constraints based on whetherf
is equal toFace::Head
or not. - If
coin
matchesCoinFace::Dime(f)
, then we add a single constraint that uses a select expression to compute the prize. - If
coin
matchesCoinFace::Quarter(f)
, then we add a single constraint that relies on amatch
expression.
As shown, nested if
statements and match
statements are allowed within match
statement arms,
as are constraint
statements.
Matches are Exhaustive
There’s one other aspect of match
we need to discuss: the arms’ patterns must cover all
possibilities. Consider the following version of variables coins
and coins_in_cents
previously
declared:
let coins_in_cents = match coins {
Coins::Penny(n) => n,
Coins::Nickel(n) => n * 5,
Coins::Quarter(n) => n * 25,
};
We didn't handle the Coins::Dime(n)
case, so this code will cause a bug. Luckily, it's a bug the
Pint compiler knows how to catch. If we try to compile this code, we'll get this error:
Error: not all match variants are covered
╭─[match.pnt:42:26]
│
42 │ ╭─▶ let coins_in_cents = match coins {
┆ ┆
46 │ ├─▶ };
│ │
│ ╰──────────── not all variants for union `::Coins` are covered by match
│
│ Help: branches and/or bindings are required for variant `Coins::Dime`
────╯
Error: could not compile `match.pnt` due to previous error
The Pint compiler knows that we didn't cover every possible case, and even knows which pattern we
forgot. Matches in Pint are exhaustive: we must exhaust every last possibility in order for the
code to be valid. This is true for both match
expressions and match
statements!
Catch-all Patterns
In the case where not every variant is significant, match
expressions and statements may employ an
else
arm. It must be declared last and will obviously have no value bound, and is useful to
evaluate to a default value for match
expressions, or to contain default declarations (or none)
for match
statements.
To get the above example to compile with a default value of zero, implying any other coins actually
have no value, an else
may be used as follows:
let coins_in_cents = match coins {
Coins::Penny(n) => n,
Coins::Nickel(n) => n * 5,
Coins::Quarter(n) => n * 25,
else => 0,
};
The code compiles, even though we haven't listed all the possible values a Coins
can have, because
the last pattern will match all values not specifically listed. This catch-all pattern meets the
requirement that match
must be exhaustive.
Constraints
Constraints are the building blocks of a Pint contract. Simply put, a constraint is a statement that
starts with the keyword constraint
followed by a Boolean expression:
constraint x == 0;
The above is a simple constraint which ensures that the value of x
is exactly 0. Every proposed
solution to this contract must set x
to 0. Otherwise, the constraint will fail. If we have
multiple constraint statements, then all of them must be satisfied for a solution to be valid. For
example:
constraint y >= 0;
constraint y <= 10;
In the above, every valid solution must set y
to a value between 0 and 10.
Note that the above is actually equivalent to:
constraint y >= 0 && y <= 10;
However, you may find it easier to structure your code into multiple separate constraints. Otherwise, you'll find yourself with a giant constraint statement that is hard to read and maintain.
As mentioned above, the expression of a constraint statement must be of type boolean. Otherwise, a compile error will be produced. For example:
constraint x + y
will result in the following error:
Error: constraint expression type error
╭─[example.pnt:16:1]
│
16 │ constraint x + y;
│ ────────┬───────
│ ╰───────── constraint expression has unexpected type `int`
│ │
│ ╰───────── expecting type `bool`
────╯
Error: could not compile `example.pnt` due to previous error
Constants
Sometimes it may be desirable to use a common constant value for re-use throughout a program which
is not a parameter to be solved or variable to be computed. These may be declared in Pint using the
const
keyword.
const
declarations resemble var
declarations in that they name an optionally typed value with an
initializer.
const minimum: int = 10;
const maximum: int = 20;
predicate Foo(foo: int, size: int) {
constraint size >= minimum && foo <= maximum;
}
Like var
declarations the type may be omitted and will be inferred by the Pint compiler, but the
const
initializer is required and must a constant expression which does not refer to predicate
parameter variables nor other non-constant values.
Constants of Compound Types
const
declarations may refer to values with compound types as long as every element within is a
constant value. Constant value initializers may also dereference other array or tuple const
declarations or even array or tuple literals.
const counts = [20, 30, 40];
const default_idx = 1;
const next_count = counts[default_idx + 1];
const min_size = { valid: true, size: 10 };
predicate Bar(my_size: int) {
constraint !min_size.valid || my_size >= min_size.size;
}
In the above example next_count
is evaluated at compile time to be fixed as 40.
The min_size
tuple is adding a flag to a value to mark whether it should be used or not in a
constraint. This may be convenient during development for turning the min_size.size
constraint on
or off.
Storage
Most useful Pint contracts require some sort of persistent storage that represent state. After all, a blockchain is a decentralized distributed database and contracts are a way to enforce rules on how "entries" in this database are allowed to change. Therefore, having the ability to express those database entries using variables and dynamic containers is quite useful. For that reason, Pint offers a way to declare and access a variety of storage types.
In this chapter, we will cover the following:
- How to declare and access storage variables with statically-sized types.
- How to declare and access storage variables with dynamically-sized types.
- How to access storage variables that belong to an external contract.
Statically-Sized Storage Types
All storage variables in a Pint contract must be declared inside a storage
block, and there can
only be a single storage
block in a Pint contract. The storage
block is optional and can be
skipped if the contract does not need to manage any state, but such contracts are generally not very
useful.
Here's an example of a storage
block:
union TokenBalance = DAI(int) | USDC(int) | USDT(int);
storage {
x: int,
bal: int,
a: b256,
t: { int, bool },
y: bool,
w: int,
arr: { int, int }[3],
v: { int, { bool, b256 } },
u: TokenBalance,
}
A storage
starts with the keyword storage
and followed by a comma-separated list of variable
declarations inside curly brackets. Each variable declaration is an identifier annotated with a
type. In this chapter, we're only looking at storage variables that have statically-sized types,
i.e., types that have known sizes and layouts at compile time. This pretty much covers every type we
discussed so far! In the next chapter, we will introduce new storage-only types that are dynamically
sized.
Back to the example above, the declared storage
block is fairly simple. It contains several
variables with different primitive and compound types. Similarly to predicate parameters, storage
variables must have a type annotation.
Accessing Storage Variables
Storage variables are not useful unless we can read them and/or propose modifications to them. Recall that Pint is a declarative constraint-based language, and therefore, does not allow "writing" directly to a storage variable. Writing directly to storage is a concept you might be familiar with from other smart contract languages like Solidity, so you might be asking yourself the following question: "if I can't write directly to storage variables, how will their values ever change? How will the state of the blockchain ever change?"
The answer to these questions lies at the core of what makes Pint and declarative blockchains
actually declarative. Pint has no concept of "updating" state (or even predicate parameters or
local variables for that matter). Pint simply expresses a desired outcome using constraint
statements and relies on solutions to actually propose state changes.
In order to express a desired outcome for a given storage variable, two things are needed:
- A way to read the current value of the variable.
- A way to express the future value of the variable.
Reading a Storage Variable
Reading a storage variable should not be an unfamiliar concept to you if you've worked with other
smart contract languages before like Solidity. The syntax for reading a
storage variable in Pint requires the storage
keyword again:
let a = storage::a;
let t = storage::t;
let t_1 = storage::t.1;
let arr_2_1: int = storage::arr[2].1;
let x = storage::x;
let incremented = storage::x + 1;
constraint incremented == x + 1; // always true!
A few things to note here:
- Storage read expressions always start with
storage::
followed by the name of the variable we're trying to read. Thestorage::
syntax means we're switching namespaces to that of thestorage
block. - Each storage read expression is used in the initializer of a local variable. In fact, storage
read expressions can only ever be used in initializers of local variables. Using a storage
read expression in other contexts, such as in
constraint storage::x == 5
, is currently illegal. - Fields or elements of a compound type in storage can be accessed individually, as in
storage::t.1
andstorage::arr[2].1
. - Arbitrary expressions that include storage read expressions can also be used to initialize local
variables. Variable
incremented
is an example of that.
Note that, while storage read expressions cannot be evaluated at compile time, they are actually known at solve-time: right before the solving process starts, every storage read expression is evaluated by directly inspecting the blockchain. The result is then used in the corresponding local variable initializer expression which becomes known in preparation for solving.
Next State
Recall that expressing a desired outcome for a given storage variable also requires a way to express the future value of the variable.
In most imperative languages, statements like x = x + 1
are commonly used to mean "update the
value of x
to be equal to the current value of x
plus 1
". Because Pint is a constraint-based
declarative language where the order of statements does not matter and there is no sequential
execution, statements like x = x + 1
cannot be written and are not logical. Instead, Pint offers a
special syntax, reserved for local variables, that means "the future value of". Here's an example:
let bal = mut storage::bal;
constraint bal' >= bal + 42;
Here, bal'
, unlike bal
, is actually unknown at solve-time. That is, bal'
must be solved
for as if it were a predicate parameter and every solution must include a proposed value for bal'
.
If, for example, the value of bal
was read to be 100
at solve-time, a solver might propose that
the next value of bal
should be 150
(i.e. bal' = 150
) which would be a valid solution because
150 >= 100 + 42
(assuming all other constraints in the predicate are also satisfied).
The '
operator can also be used for variables that have arbitrary initializers. For example:
let bal_in_dollars = price * mut storage::bal;
constraint bal_in_dollars' == price * bal'; // always true!
Basically, when validating a solution, bal_in_dollars'
is computed by plugging in the new value
of storage::bal
into the expression price * storage::bal
. That is, a valid solution must satisfy
bal_in_dollars' == price * bal'
where bal_in_dollars
is computed in this way (this just happens
to be always true in this case!).
As you can imagine, using the '
operator for variables that do not depend on storage accesses is a
no-op. For example
let z = 42;
constraint z' == z; // always true
"Mutable" Storage Accesses
In the previous section, you may have noticed that we added the mut
keyword before storage::bal
in the declaration of bal
. In Pint, storage locations are non-mutable by default. That is,
solvers cannot propose new values for a storage location unless they are solving a predicate that
allows the storage location to be mutable. This is accomplished using the mut
keyword added before
a storage access. In the example of the previous section, because mut
was added before
storage::bal
, a solver can now propose a state mutation that updates the value of x
.
When the mut
keyword as added before a storage access into a compound type, mutability applies
only to the portion of the compound type that is being accessed. For example, in the example
below:
let inner: { bool, b256 } = mut storage::v.1;
v.1
is a storage access into nested tuple v
defined in the storage
block declared earlier.
Here, both v.1.0
( a bool
) and v.1.1
(a b256
) are both allowed to be mutated, but v.0
is
not allowed to be.
"Empty" State
You may be wondering what happens if a storage variable was never previously updated but was read
anyways. In this case, there is no value stored at that storage variable and nothing can be read.
To help you reason about this, Pint provides the literal nil
to represent the absence of a
value. For example,
let w = mut storage::w;
let value_1 = (w == nil ? 0 : w);
In the example above, we first check if w
is nil
before attempting to read it. If it is nil
(i.e. currently has no value), then we initialize value_1
to 0
. Otherwise, we initialize it to
the non-empty value of w
. Without checking if w
is nil
first, and if we're not sure whether
w
has a value or not, then it is possible that the state read operation will fail causing a
panic in the VM.
The following is also a valid approach for handling nil
checks:
let value_2 = (mut storage::w == nil) ? 0 : storage::w;
constraint value_2 == value_1; // always true!
It is also possible to update a variable to nil
using the "next state" operator:
if w != nil {
constraint w' == nil;
}
Here, if w
currently has a value, then we constrain the next value of w
to be nil
.
This concludes our overview on storage which only focused on statically-sized storage types. In the next chapter, we will cover dynamically-sized storage types which offer a lot more flexibility.
Dynamically-sized Storage Types
Pint includes a number of very useful data structures called storage collections. Most other data types represent one specific value, but storage collections can contain multiple values. Unlike the built-in array and tuple types, the amount of data that these collections hold does not need to be known at compile time and can grow or shrink as the blockchain state evolve. Each kind of collection has different capabilities and costs, and choosing an appropriate one highly depends on the situation at hand. In this chapter, we'll discuss two collections that are used very often in Pint contracts:
- Storage Map: allows you to associated a value with a particular key.
- Storage Vector: allows you to store a variable number of values.
We'll discuss how to create and update storage maps and storage vectors.
Storage Map
The first collection we will look at is the storage map. A storage map stores a mapping of keys of
some type K
to values of some type V
. Storage maps are useful when you want to look up data by
using a key that can be of any type. For example, in the Subcurrency
contract, we kept track of each user’s token balance in a map in which each key is a user’s address
and the values are each user’s balance. Given a user address, you can retrieve their balance.
Creating a New Storage Map
Because storage maps are a storage type, they must always be declared inside a storage
block. The
storage map type is a built-in type with a specific syntax:
storage {
// ...
balances: (b256 => int),
// ...
}
Here, a new storage map, called balances
, is declared that maps b256
keys to int
values. The
storage map type is always declared using parentheses that contain two types separated by a =>
.
It should be clear that storage maps are homogeneous, that is, all of the keys must have the same
type as each other, and all of the values must have the same type as well.
Accessing Values in a Storage Map
We can get a value out of the storage map by providing its key in between square brackets, similar to how arrays are accessed:
let from_balance = storage::balances[from_address];
let receiver_balance = storage::balances[receiver_address];
Here, from_balance
will have the value that's associated with from_address
and
receiver_balance
will have the value that's associated with receiver_address
. Because the values
returned are storage values, they must be used in the initializers of some local variables. Using a
storage map access expression in any other context results in a compile error.
"Updating" a Storage Map
As we've mentioned a few times already, explicitly "updating" anything in Pint is not a valid operation because Pint has no sequential execution. However, as in the case with statically-sized storage types, we can require that the next value of a specific entry in a storage map satisfy some constraint(s):
let my_bal = mut storage::balances[my_address];
constraint my_bal' == my_bal + 1_000_000;
Here, we are requiring that our balance go up by 1 million, by applying the "prime" operator on the state variable that holds our current balance. Of course, requiring a change in state does not mean it will actually happen! Otherwise, we can all become instantly rich by deploying predicates like this. Unless a solution that does not violate any of the deployed rules (i.e. constraints) is submitted by a solver, the desired state change will never be satisfied.
"Missing" Keys
Now, you may be wondering what happens if a key is missing from a storage map and we try to access
it anyways. In Pint, a nil
is returned. In the previous example, if the
balance of my_address
was never actually modified in the past, then my_bal
would be equal to
nil
and therefore, the expression my_bal + 1000000
would panic. To avoid this problem, we can
first check if my_bal
is nil
before trying to use it in an arithmetic operation:
if my_bal != nil {
constraint my_bal' == my_bal + 1_000_000;
} else {
constraint my_bal' == 1_000_000;
}
Here, if my_bal
is not nil
, then the constraint remains the same as before. Otherwise, we simply
update my_bal
to 1000000
(as if my_bal
was previously 0!).
Complex Maps
Storage maps can be declared to be arbitrarily complex. They can also be nested!
storage {
// ...
complex_map: ( { int, int } => { bool, b256 } ),
nested_map: (b256 => (int => bool)),
// ...
}
In the example above, the fist storage map maps a tuple to another tuple. The second storage map
maps a b256
to another map! The way to access entries in these maps is fairly intuitive and is
exactly what you'd expect:
predicate foo(addr1: b256) {
let complex_read: b256 = storage::complex_map[{42, 69}].1;
let nested_read: bool = storage::nested_map[addr1][100];
}
The first storage access reads a tuple value using a key that itself is a tuple, and then accesses
its second field. The second storage access is a nested map access using two index operators. Note
that the first index operator accesses the first key (b256
in this case) and the second index
operator accesses the second key (int
in this case).
Illegal Uses of the Storage Map Type
It may be tempting to write code like this:
storage {
// ...
nested_map: (b256 => (int => bool)),
// ...
}
predicate test(addr: b256, my_map: (int => bool)) {
let nested_map: (b256 => (int => bool)) = storage::nested_map;
let nested_map_inner: (int => bool) = storage::nested_map[addr];
}
However, the compiler will disallow this by emitting the following errors:
Error: predicate parameters cannot have storage types
╭─[bad.pnt:7:28]
│
7 │ predicate test(addr: b256, my_map: (int => bool)) {
│ ──────────┬──────────
│ ╰──────────── found parameter of storage type ( int => bool ) here
───╯
Error: local variables cannot have storage types
╭─[bad.pnt:8:5]
│
8 │ let nested_map: (b256 => (int => bool)) = storage::nested_map;
│ ──────────────────────────────┬──────────────────────────────
│ ╰──────────────────────────────── found local variable of storage type ( b256 => ( int => bool ) ) here
───╯
Error: local variables cannot have storage types
╭─[bad.pnt:9:5]
│
9 │ let nested_map_inner: (int => bool) = storage::nested_map[addr];
│ ───────────────────────────────┬───────────────────────────────
│ ╰───────────────────────────────── found local variable of storage type ( int => bool ) here
───╯
Hopefully the error messages are clear enough. What the compiler is telling us here is that we cannot have predicate parameters or local variables that hold entire storage maps. A storage map is not exactly an object that we can store a reference to or copy/move around.
Storage Vector
Note: Storage vectors are work-in-progress
External Storage Access
It is common for one smart contract to want to reason about the state of another external smart contract. For example, a decentralized exchange contract typically requires reading and modifying the state (balances) owned by the external contracts of the tokens that are to be traded through the exchange.
In imperative smart contract languages like Solidity, reasoning about external state is typically done by calling some methods in the external contract that access or modify that contract's state. In Pint however, the storage variables of a contract are public and can be accessed from outside the contract using the contract's interface.
While a contract's interface contains all the public symbols that can be externally accessed, it does not specify the address of the contract. The address of a contract is a 256-bit value that uniquely identify the contract on the blockchain. Specifying the address is required because multiple contracts with different implementations may share the same interface.
Recall the interface of the counter example that we presented earlier:
interface Counter {
storage {
counter: int,
}
predicate Initialize(value: int);
predicate Increment(amount: int);
}
Assume that a contract with that interface has been deployed and has the following address:
const ContractID: b256 = 0x6D05CB94739FFB92CA96750EBF262A82C3ED8F05262DD26B503D732F0B74777E;
In order to access storage variable counter
from interface Counter
, we can create a path to
conter
as follows:
let counter = Counter@[ContractID]::storage::counter;
The path Counter@[ContractID]::storage::counter
has three parts separated by ::
:
- The name of the interface
Counter
followed by the address of the corresponding deployed contract in between@[..]
. - The keyword
storage
to indicate that we're accessing thestorage
block ofCounter
. counter
, the name of the storage variable we want to access.
We can now use the local variable counter
as usual. For example, we can constrain it as follows:
constraint counter' >= 100;
This implies that we want that new value of the counter (which is owned by the external contract) to
be at least 100
. This could be accomplished in different ways:
- If the current value of the counter is at least
100
, then nothing needs to be done. We don't even need to propose a solution for any predicates in the external contract. - If the current value of the counter is less than
100
, then the solution must:- Either solve predicate
Initialize
with parametervalue
set to100
or more. - Or solve predicate
Increment
with parameteramount
set to100 - counter
or more.
- Either solve predicate
Similarly to local storage access expressions, the expression
Counter@[ContractID]::storage::counter
can only be used in the right-hand side of a let
declaration.
Note: The
mut
keyword cannot be added to external storage accesses. External storage locations belong to the external contract that owns and it's up to the predicates in that contract to control their mutability.
Managine Growing Projects
As you write large programs, organizing your code will become increasingly important. By grouping related functionality and separating code with distinct features, you’ll clarify where to find code that implements a particular feature and where to go to change how a feature works.
As a project grows, you should organize code by splitting it into multiple modules and then multiple files. You can also extract parts of a project into separate packages that become external dependencies. This chapter covers all these techniques.
Pint has a number of features that allow you to manage your code’s organization, including which details are exposed, which details are private, and what names are in each scope in your programs. These features, sometimes collectively referred to as the module system, include:
- Packages: A feature of the pint tool that lets you build, test, and share projects.
- Modules and use: Let you control the organization and scope of paths.
- Paths: A way of naming an item, such as a type, a macro, or a
const
.
In this chapter, we’ll cover all these features, discuss how they interact, and explain how to use them to manage scope. By the end, you should have a solid understanding of the module system.
Pint Packages
The first part of the module system we’ll cover are packages.
A package is a bundle of one or more modules that provides a set of functionality. A package
contains a pint.toml
file that describes how to build it.
Packages can come in one of two forms: a "contract" package and a "library" package. Contract
packages represent smart contracts that can be compiled to bytecode and deployed to a blockchain.
Each contract package must have a contract.pnt
file in its src
directory, which represents the
entry point of the contract. Library packages don't compile to bytecode. Instead, they define
functionality intended to be shared with multiple projects. The entry point of a library crate is
always a lib.pnt
file in the src
directory.
Let's walk through what happens when we create a package. First, we enter the command pint new my-project
:
$ pint new my-project
Created my-project [contract] (/path/to/my-project)
$ ls my-project
pint.toml src
$ ls my-project/src
contract.pnt
After we run pint new
, we use ls
to see what the pint tool creates. In the project directory,
there's a pint.toml
file, giving us a package. There's also an src
directory that contains
contract.pnt
. Open pint.toml
in your text editor, and note there's no mention of
src/contract.pnt
. The pint tool follows a convention that src/contract.pnt
is the root file of a
contract package. Likewise, src/lib.pnt
is the root file of a library package.
Note that the pint.toml
file does contain a field called kind
, which is set to contract
in the
example above, to indicate that this particular package is a contract. In contrast, if we were to
create a library package using pint new my-library --lib
, then we would find that the kind
field
in the generated pint.toml
is set to library
.
Defining Modules
In this section, we’ll talk about modules and other parts of the module system, namely paths that
allow you to name items and the use
keyword that brings a path into scope.
Modules Cheat Sheet
Here we provide a quick reference on how modules, paths, and the use
keyword work in the compiler,
and how most developers organize their code. We’ll be going through examples of each of these rules
throughout this chapter, but this is a great place to refer to as a reminder of how modules work.
- Start from the package root: When compiling a package, the compiler first looks for code to
compile in the package root file, which is
src/lib.pnt
for a library package orsrc/contract.pnt
for a contract package. - Declaring modules: You can declare new modules by creating files for them in the appropriate
directories. Say you want to declare a new
garden
module. You have two options:- You can create the file
src/garden.pnt
if you want thegarden
module to be a single file module. That is, if you don't want the modulegarden
to have submodules. - You can create the file
src/garden/garden.pnt
if you want the module to be a multi-file module. That is, if you want the modulegarden
to have submodules. The submodules ofgarden
would then live in thesrc/garden
directory.
- You can create the file
- Declaring submodules: In any directory other than the package root directory, you can create
new submodules. For example, say you want to declare a submodule of
garden
namedvegetables
. You have two options:- You can create the file
src/garden/vegetables.pnt
if you want thevegetables
submodule to be a single file submodule. That is, if you don't want the submodulevegetables
to have its own submodules. - You can create the file
src/garden/vegetables/vegetables.pnt
if you want thevegetables
submodules to be a multi-file submodule. That is, if you want the submodulevegetables
to have its own submodules.
- You can create the file
- Paths to code in modules: Once a module is part of your package, you can refer to code in that
module from anywhere else in the same package. For example, an enum
Asparagus
in the garden vegetables module would be found at::garden::vegetables::Asparagus
. - The
use
keyword: Within a Pint file, theuse
keyword creates shortcuts to items to reduce repetition of long paths. For example, you can create a shortcut to::garden::vegetables::Asparagus
using the statementuse ::garden::vegetables::Asparagus;
declared at global scope in a Pint file. From then on, you only need to writeAsparagus
to make use of that enum in this file.
Here, we create a contract package named backyard
that illustrates these rules. The package's
directory, also named backyard
, contains these files and directories:
backyard
├── pint.toml
└── src
├── contract.pnt
└── garden
├── garden.pnt
└── vegetables.pnt
The package root file is src/contract.pnt
since this is a contract package. It contains:
use garden::vegetables::Asparagus;
predicate Foo(green_asparagus: Asparagus) {
constraint green_asparagus == Asparagus::Green;
}
The submodule vegetables
which is defined in src/garden/vegetables.pnt
, contains:
union Asparagus = Green | White | Purple;
Paths for Referring to an item in a Module Tree
To show Pint where to find an item in a module tree, we use a path in the same way we use a path when navigating a filesystem. To use a custom type for example, we need to know its path.
A path can take two forms:
- An absolute path is the full path starting from a package root; for code from an external
package, the absolute path begins with the package name, and for code from the current package, it
starts with double colons (
::
). - A relative path starts from the current module and uses an identifier in the current module.
Both absolute and relative paths are followed by one or more identifiers separated by double colons
(::
).
Returning to the "backyard" example from the previous chapter, say we want to access the enum
Asparagus
. This is the same as asking: what's the path of the Asparagus
enum. We'll show two
ways to access Asparagus
from the root file:
predicate Bar(
first_asparagus: ::garden::vegetables::Asparagus,
second_asparagus: garden::vegetables::Asparagus,
) { }
The first time we refer to the enum Asparagus
we use an absolute path. The enum Asparagus
is defined
in the same package as our root module above, which means we can use ::
to start an absolute path.
We then include each of the successive modules until we make our way to Asparagus
.
The second time we refer to the enum Asparagus
, we use a relative path. The path starts with
garden
, the name of the module defined at the same level of the module tree as the root module.
Choosing whether to use a relative or absolute path is a decision you’ll make based on your project,
and depends on whether you’re more likely to move item definition code separately from or together
with the code that uses the item. For example, if we move the garden
module to a module named
estate
, we’d need to update the absolute path to Asparagus
, but the relative path would still be
valid. However, if we moved the parameter first_asparagus
into a module named salad
, the
absolute path to Asparagus
would stay the same, but the relative path would need to be updated.
Our preference in general is to specify absolute paths because it’s more likely we’ll want to move
code definitions and item calls independently of each other.
Bringing Paths into Scope with the use
Keyword
Having to write out the paths to access items can feel inconvenient and repetitive. In the previous
chapter, whether we chose the absolute or relative path to Asparagus
, every time we wanted to use
Asparagus
we had to specify the modules garden
and vegetables
. Fortunately, there’s a way to
simplify this process: we can create a shortcut to a path with the use
keyword once, and then use
the shorter name everywhere else in the scope.
In the example below, we bring the ::garden::vegetables
module into the scope of the root file to
use the Asparagus
enum:
use ::garden::vegetables;
predicate Baz(
third_asparagus: vegetables::Asparagus,
) { }
Adding use
and a path in a scope is similar to creating a symbolic link in the filesystem. By
adding use ::garden::vegetables
in the root file, vegetables
is now a valid name in that scope.
Handling conflicting imports
Pint does not allow bringing two items with the same name into scope with use
. This name clash
makes it impossible for the compiler to distinguish between the two items in the scope. There are
two ways to avoid this problem. The first, is to only import the names of the parent modules.
Consider the following two libraries:
// Module data::contract_lib
type Data = {
address: b256,
storage_vars: int,
predicates: int,
};
// Module data::predicate_lib
type Data = {
address: b256,
parameters: int,
};
Both libraries use the name Data
to describe a types. The example below shows how to bring the two
Data
types into scope and how to refer to them without having a conflict.
use ::data::contract_lib;
use ::data::predicate_lib;
predicate test(
contract_data: contract_lib::Data,
predicate_data: predicate_lib::Data,
) {}
As you can see, using the parent module distinguishes the two Data
types. If instead we specified
use data::contract_lib::Data
and use data::predicate_lib::Data
, we'd have two Data
types in
the same scope and Pint wouldn't know which one we meant when we used Data
.
There’s another solution to the problem of bringing two types of the same name into the same scope
with use
: after the path, we can specify as
and a new local name, or alias, for the type. The
example below shows another way to write the code in the previous example by renaming the two Data
types using as
.
use ::data::contract_lib::Data as ContractData;
use ::data::predicate_lib::Data as PredicateData;
predicate test(
contract_data: ContractData,
predicate_data: PredicateData,
) { }
In each use
statement, we choose a new name for Data
. That guarantees that no conflicts arise.
This also has the side benefit of giving Data
a more meaningful name in the current context.
Using Nested Paths to Clean Up Large use
Lists
If we’re using multiple items defined in the same module, listing each item on its own line can take
up a lot of vertical space in our files. For example, these two use
statements we had in the
previous example bring two items into scope:
use ::data::contract_lib::Data as ContractData;
use ::data::predicate_lib::Data as PredicateData;
Instead, we can use nested paths to bring the same items into scope in one line. We do this by specifying the common part of the path, followed by two colons, and then curly brackets around a list of the parts of the paths that differ, as shown below.
use ::data::{
contract_lib::Data as ContractData,
predicate_lib::Data as PredicateData
};
In bigger programs, bringing many items into scope from the same module using nested paths can
reduce the number of separate use
statements needed by a lot!
We can use a nested path at any level in a path, which is useful when combining two use
statements
that share a subpath. For example, the code snippet below shows two use
statements: one that
brings data::contract_lib
into scope and one that brings data::contract_lib::Data
into scope.
use ::data::contract_lib;
use ::data::contract_lib::Data;
The common part of these two paths is data::contract_lib
, and that’s the complete first path. To
merge these two paths into one use
statement, we can use self
in the nested path, as shown
below.
use ::data::contract_lib::{self, Data};
This line brings data::contract_lib
and data::contract_lib::Data
into scope.
Advanced Features
By now, you’ve learned the most commonly used parts of the Pint programming language. We will look at a few aspects of the language you might run into every once in a while, but may not use every day. The features covered here are useful in very specific situations. Although you might not reach for them often, we want to make sure you have a grasp of all the features Pint has to offer.
In this chapter, we'll cover:
- Invoking Predicates: how to invoke external local and external predicates.
- Macros: how to write reusable code using macros.
Invoking Predicates
If you've worked with imperative smart contract languages like Solidity
in the past, you know that interactions between smart contracts are essential for many applications.
One way to do this is by calling one contract from another contract. Pint has a similar concept
where one predicate can invoke another predicate with some arguments. We use the word "invoke"
here instead of "call" because, unlike imperative languages, predicates in Pint are not called in
the traditional sense since that would imply the existence of a control flow (a "call" followed by a
"return"). Instead, in Pint, predicate A
can invoke predicate B
with some arguments to indicate
that B
has to be present in the solution with this particular set of arguments, if A
is also
present in the solution. If this sounds confusing, don't worry. It will all be clear by the end of
this chapter.
We cover two types of predicate invocations:
- External predicate invocation.
- Sibling predicate invocation.
Invoking External Predicates
Similar to accessing external storage, a predicate in an external contract can be invoked through an
interface
declaration corresponding to the external contract. When invoking the external
predicate, the following are needed:
- The address of the external contract.
- The address of the predicate we want to access in that contract.
- A list of arguments.
Consider the following external smart contract that we would like interact with:
predicate foo(
x: int,
y: bool,
t: { int, bool },
) {
// predicate logic
}
// other predicates
To interact with this contract, we first need to generate its interface:
interface MyInterface {
predicate foo(
x: int,
y: bool,
t: { int, bool },
);
// other predicates
}
Now, we can invoke predicate foo
as follows:
const CONTRACT_ADDR = 0xEB87FCE275A9AB10996D212F39221A56B90E01C37FA9D16EE04A3FE8E17DEED9;
const FOO_ADDR = 0xBA6595C5C75346E6C82BED0CE770D0758ADD1712163FCE45E38E5E8EAC6AA153;
predicate bar(
a: int,
b: bool,
) {
let tuple = { 42, b };
constraint MyInterface@[CONTRACT_ADDR]::foo@[FOO_ADDR](a, true, tuple);
}
The predicate invocation expression has three parts separated by ::
:
- The name of the interface
MyInterface
followed by the address of the corresponding deployed contract in between@[..]
. This is quite similar to what we had to do to access external storage variables. - The name of the predicate
foo
followed by the address of the corresponding predicate in the deployed contract. - A list of arguments that must match the list parameters of
foo
in theinterface
.
In the above, the constraint:
constraint MyInterface@[CONTRACT_ADDR]::foo@[FOO_ADDR](a, true, tuple);
can be interpreted as follows: any solution that solves predicate bar
must also contain a solution
for predicate foo
such that:
- The address of
foo
isFOO_ADDR
. - The address of the contract that contains
foo
isCONTRACT_ADDR
. - The arguments provided for
foo
in the solution are equal toa
,true
, andtuple
respectively.
Of course, for foo
to be actually satisfied, the containing contract must exist and be deployed,
the addresses must be correct, and the arguments must match (in number and types) what the deployed
bytecode of foo
actually expects.
Invoking Sibling Predicates
A special case for invoking predicates is when the invoked predicate is in the same contract as the predicate that is invoking it. There is no conceptual difference between this special case and the generalized case above expect that, because we are working in the same contract, there is no need to declare an interface for the contract. There is also no need to specify the address of the invoked predicate because the compiler can figure that out on its own! Here's an example:
predicate A(
a: { bool, int },
b: bool,
c: int[3],
) {
// predicate logic
}
predicate B() {
constraint A@[]( { true, 42 }, false, [1, 2, 3]);
}
Here, predicate B
invokes sibling predicate A
by using its path (just A
) and empty square
brackets @[]
to indicate that the address is to be computed by the compiler. The constraint
constraint A@[](..)
should be interpreted exactly as in the case of external predicate
invocation: any solution that solves B
should also contain a solution for A
with the provided
listed arguments.
One important caveat of the above is that you are not allowed to have cyclical dependencies between predicates. For example:
predicate A(x: int) {
constraint B@[](0);
}
predicate B(y: int) {
constraint A@[](0);
}
Here, predicate A
invokes predicate B
and predicate B
invokes predicate A
. This causes a
cycle where the address of a predicate cannot be determined and used by the other. The compiler will
emit the following error to prevent you from proceeding:
Error: dependency cycle detected between predicates
╭─[invoking_predicates_3.pnt:1:1]
│
1 │ predicate A(x: int) {
│ ─────┬─────
│ ╰─────── this predicate is on the dependency cycle
│
7 │ predicate B(y: int) {
│ ─────┬─────
│ ╰─────── this predicate is on the dependency cycle
│
│ Note: dependency between predicates is typically created via predicate instances
───╯
Another caveat is that a predicate cannot reference itself:
predicate C(x: int) {
constraint C@[](x);
}
The compiler will complain as follows:
Error: self referential predicate `::C`
╭─[invoking_predicates_4.pnt:2:16]
│
2 │ constraint C@[](x);
│ ───┬───
│ ╰───── this predicate call references the predicate it's declared in
───╯
Macros
Macros are a way of writing code that writes other code, which is known as metaprogramming. Metaprogramming is useful for reducing the amount of code you have to write and maintain. Most programming languages have "functions" which somewhat serve a similar purpose. Macros, however, are a lot more powerful. While Pint does not have "functions", its macro system is powerful enough to cover their use case.
Macro expansion is the very first operation performed by the compiler. This property implies that macros can contain anything as long as the code they produce is parsable, i.e. does not violate the grammar of Pint. Later stages of the compiler will then validate the semantics of the expanded code.
Macros in Pint have two main forms:
- Simple Macros: these are macros that accept a fixed number of parameters and do not support recursion.
- Variadic Macros: these are macros that accept a variable number of parameters and can support recursion.
Simple Macros
Simple macros take a fixed number of parameters, each starting with $
. Consider the following
simple macro:
macro @in_range($v, $num) {
constraint $v >= $num;
constraint $v < ($num * $num);
}
Macro definitions always start with the keyword macro
followed by the name of the macro, which
must start with @
. Then, a list of named parameters is provided in between parentheses. The body
of the macro is provided in between curly braces and can contain any code that uses the macro
parameters.
The macro above is named @in_range
and takes 2 parameters named $v
and $num
. In the body of
this macro, these parameters are used as expressions but this is not always necessarily the case!
When this macro is used, two constraints are always produced. Let's use this macro as follows:
let x = 42;
@in_range(x; 10);
To call the macro, we write its name followed by a list of arguments, separated by a semicolon
(;
), in between parentheses. The number of arguments must match the number of parameters that the
macro expects.
Note: yes, the arguments are separated by a semicolon (
;
) and not a comma (,
).
After the macro gets expanded, the compiler will produce code that is equivalent to the following:
let x = 42;
constraint x >= 10;
constraint x < (10 * 10);
It should hopefully be quite clear to you how this substitution happened. The compiler simply
rewrote the body of the macro by replacing $v
with x
and $num
with 10
. The resulting code is
then pasted exactly where the macro was called.
Arbitrary Tokens as Macro Arguments
The arguments to a macro call may be collections of tokens which do not necessarily parse to a
proper expression, as in the previous example. For example, an operator like +
or a type name such
as int
are valid!. If the token is an identifier, then it may be used as a name, such as the name
of a decision variable or a new type. Here's an example:
macro @do_decls($a, $a_expr, $b, $b_expr, $ty, $op) {
let $a: $ty = $a_expr;
let $b: $ty = $b_expr;
constraint $b $op $a;
}
The author of this macro likely expects:
$a
and$b
to be identifiers.$a_expr
and$b_expr
to be expressions.$ty
to be a type.$op
to be a binary operator such as+
,-
, etc.
In fact, if the rules above are not respected when calling the macro, the program will likely fail to parse correctly resulting in a failed compilation.
If we call the macro above with:
@do_decls(x1; 42; x2; 69; int; >);
the compiler will expand the macro call to:
let x1: int = 42;
let x2: int = 69;
constraint x2 > x1;
Hopefully this gives you an idea of how powerful macros can be.
Macro Expressions
So far, we've only looked at example macros where the body is a list of declarations (such as let
declarations or constraints). Macros are even more versatile than that! Macros can, in fact, produce
an expression. This would be akin to functions that return values in other programming languages.
The expression that you want produced by the macro must always be the last statement in the macro body. For example:
macro @quotion($a, $b) {
constraint $b > 0; // Declaration.
$a / $b // Final expression.
}
Because this macro produces an expression, a call to it can be used as an expression as well. For example:
let e: int = 4;
let f: int = 2;
let q: int = @quotion(e; f);
As a result, the compiler will expand the macro call to:
let e: int = 4;
let f: int = 2;
constraint f > 0;
let q: int = e / f;
Note that the expression is always inserted at the exact location where the macro was called, but any declaration items in the macro body are inserted right before the call.
Declaring Variables in Macro Bodies
Earlier, we looked at an example macro that uses some of its parameters as identifiers to declare some local variables. When that macro is called multiple times with different arguments, the resulting variable declarations will not cause any name conflicts. Now, what happens if, instead, we were to directly use an identifier in a macro body when declaring new variables? Here's an example:
macro @is_even($a) {
let half: int = $a / 2;
constraint $a == half * 2 || $a == half * 2 + 1;
}
In a naive macro system, if @is_even
was called more than once within the same module, then after
expansion there would be multiple half
variable declarations, resulting a name clash error.
To avoid this problem Pint's macro expansion aims to be hygienic and places newly declared symbols into a unique anonymous namespace. Note that this is only done for symbols which are not macro parameters. To illustrate this, consider the following:
macro @let_decls($a) {
let foo: int = 42; // Hygienic anonymous binding for `foo`.
let $a: bool = true; // Lexical binding for `$a`.
}
If we call the macro above using @let_decls(foo)
there would not be an error as the expansion
would be equivalent to:
let anon@0::foo: int = 42;
let foo: bool = true;
And even when called multiple times with different arguments there would be no error:
@let_decls(foo);
@let_decls(bar);
Becomes equivalent to:
let anon@0::foo: int = 42;
let foo: bool = true;
let anon@1::foo: int = 42;
let bar: bool = true;
Of course, if @let_decls
was called with the argument foo
multiple times there would be an
error!
Recursion and Variadic Macros
The second type of macros we will look at is "Variadic Macros". Variadic macros allow a special type of recursion via variadic parameters. Such special parameters allow macros to call themselves, essentially entering an expansion loop. Think of this as recursive code generation.
The term "variadic" means that the macro accepts a variable number of parameters. In the parameter
list of the macro definition this is indicated using a special parameter whose name starts with an
&
. Recursion may be performed via one or more recursing macros and at least one non-recursing, or
terminating macros. The recursing macros call other versions of itself but with a different number
of - usually fewer - arguments. The terminating macros do not call another version of itself. This
is best explained with an example, so let's consider the following macro which implements a sum
operation over an arbitrary number of named variables:
// Recursive Macro
macro @sum($x, $y, &rest) {
// Called only when `&rest` is not empty.
// We recurse by adding `$x` and `$y` and using `&rest` as the second argument.
@sum($x + $y; &rest)
}
// Terminating Macro
macro @sum($x, $y) {
// Called only when the number of arguments is exactly 2.
$x + $y
}
We have two versions of the @sum
macro. Despite the apparent name clash, this is actually okay
because the two macros accept a different number of parameters. The first version of @sum
accepts
$x
, $y
, and &rest
while the second version accepts only $x
and $y
. The parameter &rest
is special and is referred to as a "parameter pack". The parameter pack is never empty, therefore in
order to call the first version of @sum
, we must pass 3 or more arguments.
Note: The parameter pack is not addressable in any way. It may only be used as an argument to another macro call.
Now let's walk through how the compiler would expand a call to @sum
. You will notice that the
compiler will always try to match the number of arguments provided to the number of parameters in
each macro, and the best match will be selected.
Calling @sum(a; b)
will expand directly using the terminating macro to the expression a + b
.
Calling @sum(a; b; c; d)
will expand as follows:
@sum(a; b; c; d)
calls the recursive macro as@sum(a; b; [c, d])
where[c, d]
is&rest
.@sum(a; b; [c, d])
expands to@sum(a + b; c; d)
.@sum(a + b; c; d)
calls the recursive macro again, as@sum(a + b; c; [d])
.@sum(a + b; c; [d])
expands to@sum(a + b + c; d)
.@sum(a + b + c; d)
calls the terminating macro.@sum(a + b + c; d)
expands toa + b + c + d
, which is the final expanded form of the macro call.
Note that, since the &rest
parameter pack is passed in its expanded form, the above @sum
macros
could instead be rewritten as follows, to the same effect:
// Recursive Macro
macro @sum_v2($x, &rest) {
@sum_v2($x + &rest)
}
// Terminating Macro
macro @sum_v2($x) {
$x
}
Parameter packs can also be used by non-recursive macros which wish to call other recursive macros. A more interesting use of variadic macros might be to chain array accesses together in relative constraints:
macro @chain($a, $index, &rest) {
// Add the first link in the chain, then move to the rest.
@chain_next($a; $index; &rest)
}
macro @chain_next($a, $prev, $next, &rest) {
// Add the next link:
// constrain based on the previous link and continue.
constraint $a[$next] > $a[$prev] + 10;
@chain_next($a; $next; &rest)
}
macro @chain_next($a, $prev, $last) {
// Just expand to the final link.
constraint $a[$last] > $a[$prev] + 10;
$a[$last]
}
When called as:
predicate chain(array: int[4]) {
let r = @chain(array; 0; 1; 2; 3);
}
The following code would be produced:
predicate chain(
array: int[4],
) {
let r = array[3];
constraint (array[1] > (array[0] + 10));
constraint (array[2] > (array[1] + 10));
constraint (array[3] > (array[2] + 10));
}
Array Argument Splicing
An extension to macro argument packing is the concept of array splicing. Array splicing allows
passing the elements of an array variable, in place, as the arguments to a macro call. This is done
by prefixing the array name with a tilde ~
.
Say we want to find the sum of the elements of some array of integers. If we were to use the
variadic macro @sum
from earlier, we would have to write something like:
let sum_of_array = @sum(array[0]; array[1]; array[2]; array[3]);
The issue with the above is that it's quite verbose, especially when the array size is large. Instead, array splicing allows us to write this instead:
let sum_of_array_v2 = @sum(~array);
The compiler will then split array
into its individual elements and pass them as separate
arguments to @sum
, so that the resulting expansion is
let sum_of_array_v2 = @sum(array[0]; array[1]; array[2]; array[3]);
Array splicing is usually only useful with variadic macros which can handle arrays of different sizes, though a non-variadic macro may be called with array splicing if the array size exactly matches the number of required arguments.
An important property of array splicing is that the array element accesses are expanded in place and the argument separators are only placed between them and not at their ends! The following:
@foo(~two + ~two + ~two);
expands to:
@foo(two[0]; two[1] + two[0]; two[1] + two[0]; two[1]);
This may be a bit surprising at first, but what is really happening here is that each ~two
gets
replaced verbatim with two[0]; two[1]
and the +
signs stay exact where they were. So, the
three spliced arrays make up a total of 4 separate arguments in this specific case.
Similarly,
constraint @sum(100 + ~nums * 200) < 1000;
expands to:
constraint @sum(100 + nums[0]; nums[1]; nums[2] * 200) < 1000;
The arithmetic add and multiply are applied to the first and last elements of the array in this example.
Debugging Macros
The easiest way to debug macros is to inspect the code they expand to and compare the result to your
expectations. The way to do this is using flag --print-parsed
as follows:
pint build --print-parsed
For example, consider the following contract:
macro @in_range($v, $num) {
constraint $v >= $num;
constraint $v < ($num * $num);
}
macro @sum($x, $y, &rest) {
// Called only when `&rest` is not empty.
// We recurse by adding `$x` and `$y` and using `&rest` as the second argument.
@sum($x + $y; &rest)
}
macro @sum($x, $y) {
// Called only when the number of arguments is exactly 2.
$x + $y
}
macro @chain($a, $index, &rest) {
// Add the first link in the chain, then move to the rest.
@chain_next($a; $index; &rest)
}
macro @chain_next($a, $prev, $next, &rest) {
// Add the next link:
// constrain based on the previous link and continue.
constraint $a[$next] > $a[$prev] + 10;
@chain_next($a; $next; &rest)
}
macro @chain_next($a, $prev, $last) {
// Just expand to the final link.
constraint $a[$last] > $a[$prev] + 10;
$a[$last]
}
predicate test4(x: int, array: int[4]) {
@in_range(x; 10);
let sum_of_array = @sum(array[0]; array[1]; array[2]; array[3]);
let r = @chain(array; 0; 1; 2; 3);
}
Building this contract with the flag --print-parsed
results in the following:
$ pint build --print-parsed
Compiling to_debug [contract] (/path/to/to_debug)
Printing parsed to_debug [contract] (/path/to/to_debug)
predicate ::test4(
::x: int,
::array: int[4],
) {
let ::sum_of_array = (((::array[0] + ::array[1]) + ::array[2]) + ::array[3]);
let ::r = ::array[3];
constraint (::x >= 10);
constraint (::x < (10 * 10));
constraint (::array[1] > (::array[0] + 10));
constraint (::array[2] > (::array[1] + 10));
constraint (::array[3] > (::array[2] + 10));
}
Finished build [debug] in 8.914417ms
contract to_debug 2C7EF76D670086B5A3FA185490A8C596043319A1AA8AC496B9DCF0043B8101F5
└── to_debug::test4 2E4456C7268C180898A0CE5C4C3F0445D613AD79A0E6E73CF8319F8B2C3EFB3B
which has no macro calls left! The compiler has already expanded all the macro calls and inlined the resulting code in the appropriate locations.
Pint Reference
Reference docs for the pint
command line tool.
- The Command Reference shows the available commands.
- The Manifest Reference provides a description of all manifest fields.
Commands
The following inlines the --help
output for each command so that you can get
an idea of what pint
is capable of prior to downloading the tool and running
it yourself.
Command | Short Description |
---|---|
pint build | Build a package. |
pint new | Create a new package. |
pint plugins | List all pint plugins on path. |
Overview
$ pint --help
Pint's package manager and CLI plugin host
Usage: pint <COMMAND>
Commands:
build Build a package, writing the generated artifacts to `out/`
new Create a new package
plugins Print all pint plugins found in `PATH`
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
pint build
$ pint build --help
Build a package, writing the generated artifacts to `out/`
Usage: pint build [OPTIONS]
Options:
--manifest-path <MANIFEST_PATH>
The path to the package manifest.
If not provided, the current directory is checked and then each parent recursively until a manifest is found.
--salt <SALT>
A 256-bit unsigned integer in hexadeciaml format that represents the contract "salt". The value is left padded with zeros if it has less than 64 hexadecimal digits.
The "salt" is hashed along with the contract's bytecode in order to make the address of the contract unique.
If "salt" is provided for a library package, an error is emitted.
--print-parsed
Print the parsed package
--print-flat
Print the flattened package
--print-optimized
Print the optimized package
--print-asm
Print the assembly generated for the package
--silent
Don't print anything that wasn't explicitly requested
-h, --help
Print help (see a summary with '-h')
pint new
$ pint new --help
Create a new package
Usage: pint new [OPTIONS] <PATH>
Arguments:
<PATH>
The directory path in which the package should be created
Options:
--contract
Specify the "contract" package kind.
This is the default behaviour.
--lib
Specify the "library" package kind.
By default, new packages are created with the "contract" kind.
--name <NAME>
Optionally provide a name.
By default, the package name is the last directory in the canonicalized representation of the given path.
-h, --help
Print help (see a summary with '-h')
pint plugins
$ pint plugins --help
Print all pint plugins found in `PATH`
Usage: pint plugins
Options:
-h, --help Print help
Manifest
The pint.toml
manifest provides a high-level description of a package and its
dependencies. All projects built with the pint
tool must have a pint.toml
manifest file.
The following provides a description of each of the tables and fields within the manifest.
[package]
The package table declares high-level information about the pint package. It includes the following entries:
name
The name of the package.
name = "foo"
license
Optionally specify the license for the package.
license = "MIT"
kind
Describes whether the package is a "contract"
(the default) or a "library"
.
- library packages allow for sharing types, macros and constants between multiple different packages.
- contract packages describe a top-level contract that may be deployed.
kind = "contract"
entry-point
Optionally specify the path to the entry-point module for the package relative
to the src/
directory.
By default this is:
"contract.pnt"
for contracts and"library.pnt"
for libraries.
entry-point = "path/to/my/contract.pnt"
[dependencies]
Describes the list of external library packages that the package depends on.
Library packages allow for sharing types, macros and constants between multiple different packages.
Dependencies are declared as follows:
[dependencies]
<dependency-name-1> = { <source-type> = "<source-value>" }
<dependency-name-2> = { <source-type> = "<source-value>" }
# etc
For example:
[dependencies]
bar = { path = "path/to/bar" }
Source types
Currently only path
dependencies are supported, though we plan to support more
source types in the future (e.g. git
).
package
field
By default, the dependency name is assumed to match the name of the package that we're depending on.
In some cases we may want to give a dependency a name that differs from its package name (i.e. if multiple packages have the same name).
We can do this using the package
field. In the following, we depend on a
package named barney
, but give it the dependency name bar
:
[dependencies]
bar = { path = "../path/to/bar", package = "barney" }
[contract-dependencies]
Describes the list of external contract packages that the package depends on.
These are similar to [dependencies]
, however rather than providing a
module of items like library dependencies do, contract dependencies only provide
the contract's address, along with the address of each of its predicates. E.g.
[contract-dependencies]
baz = { path = "path/to/baz" }
Now in our package, we can refer to baz's content address with the
baz::ADDRESS
constant. Similarly, if baz
has a predicate called Foo
, we
can access Foo
's predicate address with baz::Foo::ADDRESS
.
Full Example
The following is an example of a Pint package manifest:
[package]
name = "foo"
license = "MIT"
kind = "contract"
entry-point = "path/to/my/contract.pnt"
[dependencies]
bar = { path = "../relative/path/to/bar", package = "barney" }
[contract-dependencies]
baz = { path = "/absolute/path/to/baz" }
Developer Notes
The pint.toml
manifest is implemented in the pint-manifest
crate within the
pint
repostiory. Rust developers looking to build pint
-package aware tools
or plugins downstream might find this library useful.
Appendix
The following sections contain reference material you may find useful in your Pint journey. Specifically, we will cover the following:
- Keywords: A list of all the keywords in Pint, which are reserved and cannot be used as names of macros, variables, etc.
- Compiler Intrinsics: A list of all available builtin intrinsics in Pint, which can be used to perform low level operations.
- Application Binary Interface (ABI) Spec: A specification of the JSON ABI, which is a condensed representation of a smart contract that only exposes its data.
Appendix A: Keywords
The following list contains keywords that are reserved for current use by the Pint language. As such, they cannot be used as identifiers. Identifiers are names of macros, variables, tuple fields, modules, or types.
as
- perform primitive casting or rename items inuse
statementsbool
- the Boolean typeb256
- the 256-bit hash typecond
- select between multiple expressions based on some conditionsconst
-constraint
- define a Boolean constraint that a proposed solution must satisfyelse
- fallback forif
andcond
conditionalsenum
- define an enumerationexists
- existential quantification: checks whether a statements istrue
for at least one element in a domain.false
-forall
- universal quantification: checks whether a statement istrue
for all elements in a domainif
- branch based on the result of a conditional expressionin
- checks if an element belongs to a range or to an arrayint
- basic integer typeinterface
- declare an external interfacelet
- declare a local variablemacro
- define a macromatch
- match a value to patternsmut
- allows a storage location to be mutablenil
- an empty storage valuepredicate
- define a predicateself
- used inuse
statementsstorage
- declare a storage blocktrue
-type
- define a new typeunion
- declare a new unionuse
- bring symbols into scopewhere
- denote clauses that constraint generator indices
Appendix B: Compiler Intrinsics
The Pint compiler supports a list of intrinsics that perform various low level operations that are mostly useful for building libraries. Intrinsics are generally target-specific. They give library authors access to VM-specific instructions while preserving type safety. Below is a list of all available compiler intrinsics for the Essential VM:
__address_of(name: string) -> b256
Description: Returns the content hash of predicate named name
in the same contract. The name
must be the full absolute path to the predicate, such as ::Foo
, and cannot be the name of the
predicate it's used in.
__recover_secp256k1(data_hash: b256, sig: { b256, b256, int }) -> { b256, int }
Description: Recover the public key from a secp256k1 signature.
__sha256(data: _) -> b256
Description: Returns a SHA 256 hash from the specified data.
__size_of(data: _) -> int
Description: Returns the size, in words, of an expression. This is often the same as the size of
the type of the expression (e.g. 1 word for int
, 4 words for b256
, 3 words for {int, int, bool}
, and so on). However, it can also be different, namely for expressions that can be nil
such
as storage accesses and paths to local variables.
__this_address() -> b256
Description: Returns the content hash of this predicate.
__this_contract_address() -> b256
Description: Returns the content hash of the contract that this predicate belongs to.
__verify_ed25519(data: _, sig: { b256, b256 }, pub_key: b256) -> bool
Description: Validate an Ed25519 signature against a public key.
Appendix C: Application Binary Interface (ABI)
The Application Binary Interface (ABI) is a condensed representation of a smart contract that exposes enough information about the contract to allow external contexts to interact with it. The ABI does not contain any contract logic but only its public data such as its storage variables and its predicates. The ABI is serialized in JSON format, making it both human readable and easily parsable by relevant tools.
Note This particular ABI specification is mostly relevant for the EssentialVM. Other virtual machines may have different architectures, requiring a completely different ABI format.
In this chapter, we will cover the following:
- The ABI specification.
- How to construct solutions using the ABI.
Appendix C.1: Application Binary Interface (ABI) Specification
In this chapter, we will cover the specification of the Application Binary Interface (ABI) and provide an example that showcase how the final ABI looks like in JSON format.
JSON ABI Specification
The ABI of a contract is represented as a JSON object containing the following properties:
"storage"
This is an array that describes every storage variable in the contract, i.e., every variable
declared in the storage { .. }
block. Each entry in this array is a JSON object that contains the
following properties:
"name"
: a string representing the name of the storage variable."ty"
: a JSON object representing the type of the storage variable. This is further explained in JSON Representation of Types.
"predicates"
This is an array that describes every predicate in the contract. Each entry in this array is a JSON object that contains the following properties:
"name"
: a string representing the name of the predicate."params"
: an array that contains the parameters of the predicate. Each entry in this array is a JSON object that contains the following properties:"name"
: a string representing the name of the parameter."ty"
: a JSON object representing the type of the parameter. This is further explained in JSON Representation of Types.
Note: The order in which the predicate parameters show up in the JSON is important and must match the order in which they are declared in the Pint code. When constructing a solution, that same order should also be respected.
JSON Representation of Types
Each possible Pint type is represented in the ABI as a JSON object with properties that depend on the type. Below is a list of the JSON objects for each possible type:
int
"Int"
bool
"Bool"
b256
"B256"
Union
{
"Union": {
"name": <union_name>,
"variants": [
{
"name": <variant1_name>,
"ty": <variant1_ty>
},
{
"name": <variant2_name>,
"ty": <variant2_ty>
},
...
]
}
}
In the above, <variant1_name>
, <variant2_name>
, ... are strings representing the names of the
union variants. <variant1_ty>
, <variant2_ty>
, ... are JSON objects representing the types of the
tuple fields, formatted according to the rules of this section. These are optional, that is, they
can be set to null
if the corresponding variants don't hold any values.
Tuple
{
"Tuple": [
{
"name": <field1_name>,
"ty": <field1_ty>
}
{
"name": <field2_name>,
"ty": <field2_ty>
}
...
]
}
In the above, <field1_name>
, <field2_name>
, ... are strings representing the names of the tuple
fields. These are optional, that is, they can be set to null
if the corresponding tuple fields
have no names. <field1_ty.
, <field2_ty>
, ... are JSON objects representing the types of the
tuple fields, formatted according to the rules of this section.
Array
{
"Array": {
"ty": <element_ty>,
"size": <array_size>
}
}
In the above, <element_ty>
is a JSON object representing the type of each element in the array,
formatted according to the rules of this section. <array_size>
is an integer representing the size
of the array.
Storage Map
{
"Map": {
"ty_from": <ty_from>,
"ty_to": <ty_to>,
}
}
In the above, <ty_from>
and <ty_to>
are JSON objects representing the "from" type and the "to"
type in the map, formatted according to the rules of this section.
Example
Here's an example contract and its corresponding JSON ABI:
union U = A(int) | B | C(int[3]);
storage {
s0: b256,
s1: { int, int },
my_map: ( int => { int, int } ),
my_union: U
}
predicate foo(
v0: int,
v1: bool[5],
v2: U,
v3: { int, int }[5],
) {
}
{
"predicates": [
{
"name": "::foo",
"params": [
{
"name": "::v0",
"ty": "Int"
},
{
"name": "::v1",
"ty": {
"Array": {
"ty": "Bool",
"size": 5
}
}
},
{
"name": "::v2",
"ty": {
"Union": {
"name": "::U",
"variants": [
{
"name": "U::A",
"ty": "Int"
},
{
"name": "U::B",
"ty": null
},
{
"name": "U::C",
"ty": {
"Array": {
"ty": "Int",
"size": 3
}
}
}
]
}
}
},
{
"name": "::v3",
"ty": {
"Array": {
"ty": {
"Tuple": [
{
"name": null,
"ty": "Int"
},
{
"name": null,
"ty": "Int"
}
]
},
"size": 5
}
}
}
]
}
],
"storage": [
{
"name": "s0",
"ty": "B256"
},
{
"name": "s1",
"ty": {
"Tuple": [
{
"name": null,
"ty": "Int"
},
{
"name": null,
"ty": "Int"
}
]
}
},
{
"name": "my_map",
"ty": {
"Map": {
"ty_from": "Int",
"ty_to": {
"Tuple": [
{
"name": null,
"ty": "Int"
},
{
"name": null,
"ty": "Int"
}
]
}
}
}
},
{
"name": "my_union",
"ty": {
"Union": {
"name": "::U",
"variants": [
{
"name": "U::A",
"ty": "Int"
},
{
"name": "U::B",
"ty": null
},
{
"name": "U::C",
"ty": {
"Array": {
"ty": "Int",
"size": 3
}
}
}
]
}
}
}
]
}
Here's how we would interpret this JSON ABI:
- This contract has a single predicate called
::foo
, which is the full path of thefoo
predicate in the contract above. - Predicate
::foo
has three parameters:- At index 0, we have
::v0
of typeint
. - At index 1, we have
::v1
of typebool[5]
. - At index 2, we have
::v2
of typeU
which is a union with three variantsU::A
,U::B
, andU::C
. - At index 3, we have
::v3
. It's an array of 5 tuples, where each tuple contains twoint
s with no field names.
- At index 0, we have
- The contract also has four storage variables:
- The first is called
s0
and is of typeb256
. - The second is called
s1
and is a tuple of twoint
s. - The third is called
my_map
and is a storage map fromint
to a tuple of twoint
s. - The fourth is called
my_union
and is a union with three variantsU::A
,U::B
, andU::C
.
- The first is called
Appendix C.2: Constructing Solutions using the ABI
The Application Binary Interface (ABI) of a contract provides all the essential information needed
to construct a solution for one or more predicates within the contract. Although you could perform
this manually, the pint-abi
crate makes it much more
ergonomic in Rust. The pint-abi
crate includes the gen_from_file
macro, which automatically
generates the modules, types, and builder methods required to accomplish this.
The gen_from_file
Macro
Consider the following simple contract in Pint:
storage {
s_x: int,
s_y: bool,
s_z: {int, b256},
s_a: {bool, int}[2],
s_u: MyUnion,
m1: (int => b256),
m2: (int => (int => {bool, int})),
}
union MyUnion = A(int) | B;
predicate MyPredicate(
x: int,
y: bool,
z: {int, b256},
a: {bool, int}[2],
u: MyUnion,
) {
// Check arguments
constraint x == 42;
constraint y == true;
constraint z == {2, 0x1111111100000000111111110000000011111111000000001111111100000000};
constraint a == [{true, 1}, {false, 2}];
constraint u == MyUnion::A(3);
let s_x = mut storage::s_x;
let s_y = mut storage::s_y;
let s_z = mut storage::s_z;
let s_a = mut storage::s_a;
let s_u = mut storage::s_u;
let m1_42 = mut storage::m1[42];
let m2_5_6 = mut storage::m2[5][6];
// Update state
constraint s_x' == 7;
constraint s_y' == true;
constraint s_z' == {8, 0x2222222200000000222222220000000022222222000000002222222200000000};
constraint s_a' == [{false, 3}, {true, 4}];
constraint s_u' == MyUnion::B;
constraint m1_42' == 0x3333333300000000333333330000000033333333000000003333333300000000;
constraint m2_5_6' == {true, 69};
}
If you have pint-abi
added as dependency in your Rust project, you'll have access to the
gen_from_file
macro which can be called as follows:
#![allow(unused)] fn main() { pint_abi::gen_from_file! { abi: "abi_gen_example/out/debug/abi_gen_example-abi.json", contract: "abi_gen_example/out/debug/abi_gen_example.json", } }
The macro takes two arguments:
abi
: a path to the ABI of the contract in JSON formatcontract
: a path to the bytecode of the contract in JSON format
The macro above expands to a set of modules, types, and builder methods that allow creating data for solutions directly using Rust types without having to do the encoding manually.
In order to construct a solution to a predicate, you need construct two vectors:
- A vector of all the predicate arguments.
- A vector of all the state mutations.
Predicate Arguments
In order to construct a vector of arguments for the predicate above, you can use the following syntax:
#![allow(unused)] fn main() { let arguments = MyPredicate::Vars { x: 42, y: true, z: (2, [0x1111111100000000; 4]), a: [(true, 1), (false, 2)], u: MyUnion::A(3), }; }
The module MyPredicate
(which clearly corresponds to the predicate MyPredicate
in our contract
above) and the struct Vars
are readily available to us from the expansion of the gen_from_file
macro. The fields of the struct Vars
exactly match the list of parameters of MyPredicate
, both
in name and type.
Each predicate parameter Pint type has a corresponding Rust type as follows:
Pint Type | Rust Type |
---|---|
int | i64 |
bool | bool |
b256 | [i64, 4] |
Union | Enum |
Tuple | Tuple |
Array | Array |
State Mutations
In order to construct a vector of proposed state mutations for the contract above, you can use the following syntax:
#![allow(unused)] fn main() { let state_mutations: Vec<essential_types::solution::Mutation> = storage::mutations() .s_x(7) .s_y(true) .s_z(|tup| tup._0(8)._1([0x2222222200000000; 4])) .s_a(|arr| { arr.entry(0, |tup| tup._0(false)._1(3)) .entry(1, |tup| tup._0(true)._1(4)) }) .s_u(MyUnion::B) .m1(|map| map.entry(42, [0x3333333300000000; 4])) .m2(|map| map.entry(5, |map| map.entry(6, |tup| tup._0(true)._1(69)))) .into(); }
The module storage
and the function mutations()
are readily available for us from the expansion
of the gen_from_file
macro. The resulting object is of type
Vec<essential_types::solution::Mutation>
from the
essential_types
crate.
Because all state mutations are optional, they need to be set individually using their own builder
methods (unlike predicate arguments). For simple types like int
, bool
, b256
, and unions, the
syntax is self explanatory. Those types have corresponding Rust types as for predicate arguments:
Pint Type | Rust Type |
---|---|
int | i64 |
bool | bool |
b256 | [i64, 4] |
Union | Enum |
For more complex types like tuples, arrays, and storage maps, you need to provide closures that specify how to set each part of the compound type:
- For tuples, you have available the builder methods
_0(..)
,_1(..)
, etc. that specify a value for each individual entry of the tuple. you may skip some of these methods if you do not want to propose any mutations to the corresponding tuple field. - For arrays, you have the builder method
entry(..)
which takes an integer index and a value to set the array value at that particular index. Not all array indices need to be set. - For storage maps, you also have the builder method
entry(..)
which takes a key and a value to set the map value at that particularly key. The types of the key and the value must match the types of the key and the value of the map.
Contract and Predicate Addresses
The expansion of the macro gen_from_file
also provides the address of the contract and the address
of each predicate as constants:
#![allow(unused)] fn main() { let contract_address = ADDRESS; let my_predicate_address = MyPredicate::ADDRESS; }
These constants can be used to construct solutions when specifying what predicate is being solved.
Producing Solutions
We now have everything we need to produce a solution. When working with the EssentialVM, a
Solution
object can be constructed as follows:
#![allow(unused)] fn main() { let solution = essential_types::solution::Solution { data: vec![essential_types::solution::SolutionData { predicate_to_solve: MyPredicate::ADDRESS, decision_variables: arguments.into(), state_mutations, }], }; }
where we have used the address of the MyPredicate
to specify which predicate to solve, the vector
arguments
to specify values for decision_variables
(recall that we sometimes refer to predicate
parameters as decision variables), and the vector state_mutations
to specify the proposed state
mutations.
Note that this solutions only has a single SolutionsData
. In general, solutions may contain
multiple SolutionData
objects which can all be produced by following the steps above.
Storage Keys
It is sometimes useful to know the storage keys where a particular storage variable (or some of its
parts) are stored. The expansion of the gen_from_file
macro also provides the builder method
storage::keys()
which can be used as follows:
#![allow(unused)] fn main() { let keys: Vec<essential_types::Key> = storage::keys() .s_x() .s_y() .s_z(|tup| tup._0()._1()) .s_a(|arr| { arr.entry(0, |tup| tup._0()._1()) .entry(1, |tup| tup._0()._1()) }) .s_u() .m1(|map| map.entry(42)) .m2(|map| map.entry(5, |map| map.entry(6, |tup| tup._0()._1()))) .into(); }
The method keys()
is readily available in the module storage
. Similarly to mutations()
, the
key(s) for each storage variable must be appended using the corresponding builder method and the
syntax is fairly similarly to the builder methods for state mutations. The result is a vector of
Key
s which can be used to query the state for example.
Appendix D: Storage Keys Assignment
In the Essential VM, contract storage is organized as a key-value map, where both keys and values
can consist of an arbitrary number of 64-bit words. In Pint, storage variables declared within
storage { .. }
blocks are implemented using this key-value structure. This appendix explains how the
Pint compiler determines storage keys based on the types of storage variables.
Primitive Types and Unions
Primitive types and unions are always stored in a single storage slot with a single key. The key chosen has a single word which is equal to the index of the variable in the storage block.
Arrays and Tuples
Arrays and tuples are flattened and spread out across multiple consecutive slots such that each slot contains a primitive type or a union. The key for each slot consists of two words. The first word is the index of the tuple or array variable in the storage block and the second word is the index of the tuple field or array element in the flattened list.
Storage Maps
Entries of storage maps are stored in one or more storage slots depending on their types:
Primitive Types and Unions in Storage Maps
Primitive types and unions in a storage map are stored in a single storage slot with a single key. The key chosen has two components. The first component (1 word) is the index of the storage map in the storage block and the second component is storage map key (which can have multiple words).
Arrays and Tuples in Storage Maps
Arrays and tuples in storage maps are flattened and spread out across multiple consecutive slots such that each slot contains a primitive type or a union. The key for each slot consists of three components. The first component (1 word) is the index of the storage map in the storage block, the second component is storage map key (which can have multiple words), and the third component (1 word) is the tuple field or array element in the flattened list.
Nested Types
Nested types in storage adhere to the outlined rules above recursively. For instance, in the case of nested maps, the storage key is constructed by appending map keys at each level, followed by handling the inner type according to the same rules. This process is illustrated more clearly in the example below.
Example
Consider the following storage block:
union MyUnion = A | B({ int, b256 });
storage {
x: int,
y: bool,
z: b256,
u: MyUnion,
c: { int[3], { b256, bool }},
m1: ( int => int ),
m2: (int => { b256, ({ int, int } => { int, b256 }) }),
}
In the above, here are some storage accesses and the corresponding storage keys according to the rules laid out above.
Storage Access | Key(s) |
---|---|
storage::x | [0] |
storage::y | [1] |
storage::z | [2] |
storage::u | [3] |
storage::c.0[0] | [4, 0] |
storage::c.0[2] | [4, 2] |
storage::c.0 | [4, 0] ,[4, 1] ,[4, 2] , |
storage::c.1.0 | [4, 3] |
storage::c.1.2 | [4, 4] |
storage::m1[42] | [5, 42] |
storage::m1[51] | [5, 51] |
storage::m2[69].0 | [6, 69, 0] |
storage::m2[69].1[{41, 42}].0 | [6, 69, 1, 41, 42, 0] |
storage::m2[69].1[{41, 42}].1 | [6, 69, 1, 41, 42, 1] |
storage::m2[69].1[{41, 42}] | [6, 69, 1, 41, 42, 0] , [6, 69, 1, 41, 42, 0] |
Note that the keys produced by the storage::keys()
abstraction explained in the Storage
Keys from the Appendix C.2 follow the same rules
above and will produce the exact same keys.
Appendix E: Known Issues and Missing Features
Known Issues
- #913: type checker sometimes misses
type errors in
const
initializers.
Missing Features
- Strings
- Dynamic arrays, i.e., array types where the size is a value that is not known at compile time.
- Morphisms such that
map
,fold
,filter
, etc. that will allow morphing arrays. - Storage vectors