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 programs
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 {
state 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
state
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 4.157208ms
contract counter C276E4E5EAF789F82B671E53F0B202AE357C511F2B711E4921310946ACE63B7C
└── counter::Increment 3D7ABBA5C62DF177EFD61CA9D74ED055B29267404112583FA2E4C0D98B828149
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 {
var value: int;
state counter: int = mut storage::counter;
constraint counter' == value;
}
predicate Increment {
var amount: int;
state 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
declaring three statements. Let's walk through the first predicate named Initialize
:
- The first statement declares a decision variable. Decision variables are quite different from
"regular" variables which you might be familiar with from imperative languages. Decision
variables are variables that the solver is required to find values for. You can think of them
as "arguments" that the solver has to set such that every
constraint
in the predicate evaluates totrue
. InInitialize
, we are declaring a single decision variable calledvalue
of typeint
. This is the value that we want our counter to get initialized to. - The second statement declares a
state
variable and initializes it tomut storage::counter
. State variables are special variables that always need to be initialized to a storage access expression. The statementstate counter: int = mut storage::counter
creates a state 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
state counter
must be equal tovalue
. Note the'
notation here which can be only applied to astate
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 decision variables in 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:
# decision variables:
value: 42
# state mutations:
0: 42
This solution proposes a value of 42
for the decision variable 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:
# decision variables:
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 {
var receiver: b256;
var amount: int;
state receiver_balance = mut storage::balances[receiver];
state 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 {
var from: b256;
var receiver: b256;
var amount: int;
state from_balance = mut storage::balances[from];
state 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 declares two decision variables
receiver: b256
andamount: int
. The goal of this predicate to mintamount
coins and send them toreceiver
. - It initializes a
state
variable calledreceiver_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 state 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 declares three decision variables
from: b256
,receiver: b256
, andamount: int
. The goal of this predicate to sendamount
coins from addressfrom
to addressreceiver
. - It initializes a
state
variable calledfrom_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, we will cover authentication in a later chapter and discuss how to use authentication make this contract more secure.
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 decision variables, basic types, custom types, 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.
Decision Variables
A decision variable is a named variable that every solution is required to assign a value for. Decision variables are quite different from "regular" variables that you might be used to in imperative programming languages. Decision variables do not get "computed" or "assigned to" in a Pint program since a Pint program is not actually executed but solved (and later validated against a solution).
Decision variables can be declared using the var
keyword and may 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 declared a decision variable named foo
of type int
:
var foo: int;
You can actually think of the type annotation as a "constraint" on the decision variable: this
decision variable can only take values in the set of signed integers (64-bit signed integers when
targeting the EssentialVM). Any solution that proposes a value of foo
must satisfy that
constraint. We will go over constraints in more detail in Chapter 3.6.
A decision variable can also be "initialized". Initializing a decision variable may seem like an odd concept because decision variables are declared to be solved for. However, an initialized decision variable is simply a decision variable with an extra implicit constraint. Here's how an initialized decision variable can be declared:
var bar: int = 42;
The above is equivalent to:
var bar: int;
constraint bar == 42;
We will go over constraint
statements in more details later but it should hopefully be intuitive
that this statement enforces bar
to be equal to 42
. Therefore, every proposed solution must also
set bar
to 42
. Otherwise, this particular constraint will fail and the whole solution will be
deemed invalid.
Skipping the type annotation is only allowed if the decision variable is "initialized":
var baz = 43;
In this case, the compiler is able to infer the type of baz
by realizing that the initializer
42
is of type int
.
Again, the above is equivalent to:
var baz: int;
constraint baz == 43;
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 decision 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 var
statement:
// addition
var sum = 1 + 2 + 3;
// subtraction
var difference = 15 - 1;
// multiplication
var product = 42 * 42;
// division
var quotient = 3 / 2;
var truncated = -5 / 3; // Results is -1
// remainder
var 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:
var t = true;
var 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:
var addr1 = 0x3333333333333333333333333333333333333333333333333333333333333333;
var 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:
var 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:
var tup_2: { int, int, bool } = { 42, 4, true };
var tup_2_first = tup_2.0;
var tup_2_second = tup_2.1;
var 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:
var 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:
var tup_4: { x: int, int, y: bool } = { 42, 4, true };
var tup_4_first = tup_4.0;
var tup_4_first_named = tup_4.x; // same as `tup_4.0`
var tup_4_second = tup_4.1;
var tup_4_third = tup_4.2;
var 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:
var empty: {} = {};
is disallowed and errors out as follows:
Error: empty tuple types are not allowed
╭─[ch_3_2.pnt:43:12]
│
43 │ var empty: {} = {};
│ ─┬
│ ╰── empty tuple type found
────╯
Error: empty tuple expressions are not allowed
╭─[ch_3_2.pnt:43:17]
│
43 │ var 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:
var 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:
var 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:
var c: int[5] = [1, 2, 3, 4, 5];
var c_first = c[0];
var 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:
var 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
var 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)".
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:
var number: int;
var 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 decision 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:
var number: int;
var y = number < 5 ? 1 : true;
we will get the following error:
Error: branches of a select expression must have the same type
╭─[test.pnt:3:9]
│
3 │ var 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:
var number: int;
var y = number ? 1 : 2;
we will get the following error:
Error: condition for select expression must be a `bool`
╭─[test.pnt:3:9]
│
3 │ var 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:
var x: int;
var 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:
var x: int;
var 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 decision 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 an enum
. Enums are another special class of custom types that
define enumerations with named variants.
Both custom type and enum declarations must be made at the top level of a module, and are not
allowed inside a predicate
declaration.
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
:
var x: int = 5;
var 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:
var 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
.
Enumerations
Enums 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
, enums 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 an enum
.
Let’s look at a situation we might want to express in code and see why enums are useful. Say we need to work with three possible tokens: DAI, USDC, and USDT. Because these are the only tokens we want to work with, we can enumerate all possible variants, which is where enumeration gets its name:
enum 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. Also, we can now create an instance of each of the three
variants of Token
like this:
var dai: Token = Token::DAI;
var usdc: Token = Token::USDC;
var usdt: Token = Token::USDT;
Note that the variants of the enum 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 on how large some
value amount
is.
var amount: int;
var token_type: Token = cond {
amount in 0..1000 => Token::DAI,
amount in 1001..2000 => Token::USDC,
else => Token::USDT,
};
We can even use enums inside structs as follows:
type Bal = {
token: Token,
bal: int,
};
predicate balances {
var user1_bal = {
token: Token::DAI,
bal: 42,
};
var user2_bal = {
token: Token::USDC,
bal: 96,
};
var user3_bal = {
token: Token::USDT,
bal: 100,
};
}
Constraints
Constraints are the building blocks of a Pint contract. Simply put, a constraint is a declaration
that starts with the keyword constraint
followed by a Boolean expression:
var x: int;
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 declarations, then all of them must be satisfied for a solution to be valid. For example:
var y: int;
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 declaration that is hard to read and maintain.
As mentioned above, the expression of a constraint declaration 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 decision variable to be solved. 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 {
var foo: int;
var 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 decision
variables nor other non-constant values such as state.
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 {
var 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.
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 and contains a list of
constraints. 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 {
// variables, constraints, etc.
}
predicate Bar {
// variables, constraints, etc.
}
// other constraint sets
The order of the different 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!) and nothing is ever "called". Instead, solutions have to submitted that satisfy one of the predicates in the contract. A solution must specify concrete values for all the decision variables in the predicate, as well as propose changes to the state. If the proposed state changes and the decision variables satisfy all the constraints in that particular predicate, then the solution is deemed valid and the proposed state changes are committed.
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.
A contract interface has the following structure:
interface ExternalContract {
storage {
// storage variables
}
predicate Foo {
// all **public** decision variables
}
predicate Bar {
// all **public** decision variables
}
// other constraint sets
}
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. These
predicate interfaces contain all the public decision variables that the predicates expose. We
will discuss public decision variables in detail in Chapter 7.1, but for
now, you can think of these variables as regular decision variable except that they can be read from
an external context.
Let's revisit the counter example but with a small modification where we
have made all decision variables pub
, which marks them as public:
storage {
counter: int,
}
predicate Initialize {
pub var value: int;
state counter: int = mut storage::counter;
constraint counter' == value;
}
predicate Increment {
pub var amount: int;
state counter: int = mut storage::counter;
constraint counter' == counter + amount;
}
An interface for the contract above looks like this:
interface Counter {
storage {
counter: int,
}
predicate Initialize {
pub var value: int;
}
predicate Increment {
pub var 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 all public decision variables
in each predicate.
Note that, if a predicate Foo
has no public decision variables, both predicate Foo { }
and predicate Foo;
can be used when adding it to the interface.
Note in the future, Pint will have a tool that will auto-generate interfaces.
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:
storage {
x: int,
a: b256,
t: { int, bool },
y: bool,
w: int,
arr: { int, int }[3],
v: { int, { bool, b256 } },
}
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 three
variables that have primitive types and one variable that has a compound type (a tuple). Unlike
decision variables, storage variables do not accept an initializer and 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 decision 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:
state x = storage::x;
state a = storage::a;
state t = storage::t;
state t_1 = storage::t.1;
state arr_2_1 = storage::arr[2].1;
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 to initialize a
state
variable. In fact, storage read expressions can only ever be used to initializestate
variables. Using a storage read expression in other contexts, such as inconstraint storage::x == 5
, is illegal. - Fields or elements of a compound type in storage can be accessed individually, as in
storage::t.1
andstorage::arr[2].1
.
We haven't really explained what a state
variable is so this is probably a good time to do so.
State Variables
A state variable is a special type of variables that can only hold values read from storage. A
state
variable must always have an initializer and that initializer can only be a storage read
expression. Type annotations for state
declarations are optional:
state not_annotated = storage::x;
state annotated: b256 = storage::a;
Once a state
variable is declared, it can be used anywhere in its scope as if it were a decision
variable:
state t_0 = storage::t.0;
state y = storage::y;
constraint y && t_0 >= 42;
This is an example where two state variables are declared and later constrained as if they were
decision variables. One important distinction to note here is that state
variables are not
actually unknown. By definition, decision variables are unknown at compile time and at
solve-time and they only become known after the solving process is finished (if a solution is
found). In contrast, state
variables, while unknown at compile time, 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 assigned to the corresponding state
variable 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 state
variables, that means "the future value of". Here's an example:
state bal = mut storage::x;
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 decision variable 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).
"Mutable" Storage Accesses
In the previous section, you may have noticed that we added the mut
keyword before storage::x
in
the state
declaration. 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::x
,
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:
state 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,
state w = mut storage::w;
var value: int = (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 constrain value
to 0
. Otherwise, we constrain 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 panic.
It is also possible to update a state
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
using a hashing function, which determines how it places
these keys and values in storage. Hash 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 HashMap
We can get a value out of the storage map by providing its key in between square brackets, similar to how arrays are accessed:
var from_address: b256;
var receiver_address: b256;
state from_balance = storage::balances[from_address];
state 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, we must assign them to state
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):
var my_address: b256;
state my_bal = mut storage::balances[my_address];
constraint my_bal' == my_bal + 1000000;
Here, we're 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 submitting 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 + 1000000;
} else {
constraint my_bal' == 1000000;
}
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 should is fairly intuitive and
is exactly what you'd expect:
state complex_read: b256 = storage::complex_map[{42, 69}].1;
var addr1: b256;
state 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 {
var addr: b256;
state nested_map = storage::nested_map; // Expecting to return the "whole" map
state nested_map_inner = storage::nested_map[addr]; // Expecting to return the "whole" inner map
}
However, the compiler will disallow this by emitting the following errors:
Error: state variables cannot have storage map type
╭─[bad.pnt:8:1]
│
8 │ state nested_map = storage::nested_map; // Expecting to return the "whole" map
│ ───────────────────┬──────────────────
│ ╰──────────────────── found state variable of type storage map here
───╯
Error: state variables cannot have storage map type
╭─[bad.pnt:9:1]
│
9 │ state nested_map_inner = storage::nested_map[addr]; // Expecting to return the "whole" inner map
│ ─────────────────────────┬────────────────────────
│ ╰────────────────────────── found state variable of type storage map here
───╯
Hopefully the errors are clear enough. What the compiler is telling us here is that we cannot
declare state
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 and state variables can only have statically-sized
types!
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 being 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.
Interface Instance
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 256-bit value that uniquely identify the contract on the blockchain. Specifying the address is required because multiple contracts with different addresses may share the same interface.
In order to specify which external contract to actually interact with, we need an interface instance. Consider the counter example that we presented earlier and its interface that looks like this:
interface Counter {
storage {
counter: int,
}
predicate Initialize;
predicate Increment;
}
Assume that a contract with that interface has been deployed and has the following address:
const ContractID: b256 = 0x0003000300030003000300030003000300030003000300030003000300030003;
In order to interact with that particular contract, we would first declare an interface instance as follows:
interface CounterInstance = Counter(ContractID);
External Storage Access Using the Interface Instance
Now that we have an instance of the interface, we can use it to create a path to the external storage variable. For example,
state counter = CounterInstance::storage::counter;
Note that the path CounterInstance::storage::counter
has three parts:
- The name of the interface instance
CounterInstance
that indicates which instance we would like to access. Recall that there could be multiple interface instances, with different addresses, for the sameinterface
, hence the need to start the path with the name of the interface instance and not the name of interface itself. - The keyword
storage
to indicate that we're accessing thestorage
block ofCounterInstance
. counter
, the name of the storage variable we want to access.
Once we have assigned the external storage expression to a state
variable, we can then use that
variable as we usually do.
Similarly to local storage access expressions, the expression CounterInstance::storage::counter
can only be used on the right-hand side of a state
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.rs
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.rs
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.rs
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.rs
└── vegetables.rs
The package root file is src/contract.rs
since this is a contract package. It contains:
use garden::vegetables::Asparagus;
predicate Foo {
var green_asparagus = Asparagus::Green;
}
The submodule vegetables
which is defined in src/garden/vegetables.pnt
, contains:
enum 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 {
var first_asparagus: ::garden::vegetables::Asparagus;
var 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 var 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 {
var 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,
decision_vars: int,
pub_vars: 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 {
var contract_data: contract_lib::Data;
var 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 {
var contract_data: ContractData;
var 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:
- Public decision variables: how to declare decision variables that are public and how to access them from external contexts.
- Macros: how to write reusable code using macros.
Public Decision Variables
By default, all decision variables are private. That is, they are only accessible from within the predicate that declares them. There is no way to reason about the value of a private decision variable from outside the predicate. This, however, may not be sufficient to build certain applications. It also imposes limitations on how contracts are written: if a predicate requires data from another predicate, then the two predicates must be combined.
We've already looked at one class of variables that are always public: storage variables. While
storage variables allow us to share data between predicates, they are the wrong tool for our use
case because they are persistent. What we want is a class of variables that can be shared between
predicates but that are temporary. That is, their value should no longer be relevant after a
solution is submitted and validated. Pint solves this problem by allow decision variables to be
public via the pub
keyword.
Declaring Public Decision Variables
Declaring public decision variables is fairly simple. All you have to do is prefix the var
declaration with the keyword pub
:
predicate Foo {
pub var x: int;
pub var y: b256;
pub var t: { int, bool, b256 };
var a: int;
var b: bool;
var c: int[4];
constraint a + x > 3;
constraint y == 0x1111111111111111111111111111111111111111111111111111111111111111;
constraint !t.1 && b;
}
In the predicate above, we are declaring 3 public decision variables and 3 private decision variables.
Accessing public decision variables within the predicate that defines them is no different from accessing private decision variables. The example above also declares 3 constraints that access both types of decision variables in various ways.
Public Decision Variables in Interfaces
Recall that for each contract, an interface can be produced that exposes all public elements of a contract: the storage block as well as all the predicates and their public decision variables. For example, the following interface can be produced from the contract above:
interface MyInterface {
// storage block, if present
predicate Foo {
pub var x: int;
pub var y: b256;
pub var t: { int, bool, b256 };
}
// other predicates, if present
}
Note that everything in predicate Foo
was dropped except for pub var
declarations.
Next, we will show how to use an interface to access public decision variables of an external predicate.
External Access to Public Decision Variables
In Chapter 5.3 we covered how to use interfaces to access storage variables from an external context. Accessing public decision variables from an external context requires similar steps.
First, we need to declare an interface instance using the syntax we learned in Chapter 5.3
interface MyInterfaceInstance = MyInterface(ContractID);
where ContractID
is the address of the contract where the public decision variables are declared.
Then, and because the public decision variables live inside a predicate, we need to declare a
predicate instance as follows:
predicate FooInstance = MyInterfaceInstance::Foo(PredicateID);
Similarly to interface instance declarations, a predicate instance declaration requires an address.
Each predicate has a 256-bit address that identifies it on the blockchain. The above declares an
interface instance called FooInstance
of predicate Foo
and with address PredicateID
. Note that
we refer to the predicate Foo
in the declaration using the interface instance name followed by
::Foo
.
Now that we have an instance of Foo
, we are able to access its public decision variables using a
path that contains the predicate instance name and the name of the variable:
var m: int;
var n: bool;
constraint m * FooInstance::x > 8;
constraint FooInstance::y == 0x2222222222222222222222222222222222222222222222222222222222222222;
constraint FooInstance::t.1 && n;
Public Decision Variables in Solutions
Sharing data between two predicates using public decision variables only makes sense if the solution
that we are trying to validate actually solves both predicates. For example, if predicate Foo
exposes pub var x;
and predicate Bar
uses x
, then a solution that solves Bar
must also solve
Foo
since Bar
requires data from Foo
, namely x
.
Every solution contains a list of the predicates it solves. Some predicates might even be solved multiple times in the same solution! This may be surprising to you but you can imagine multiple instances of the same predicate being solved with different values. After all, predicates can generally be solved in many different ways depending on how restrictive their constraints are.
As a result, each predicate instance declaration must somehow specify which part of the solution it is referring to. The Pint compiler accomplishes this by adding an implicit decision variable that represents the index of the solved predicate in the solution. An implicit decision variable is added for each predicate instance declaration. Therefore, in the example below:
interface MyInterfaceInstance = MyInterface(ContractID);
predicate FooInstance1 = MyInterfaceInstance::Foo(PredicateID);
predicate FooInstance2 = MyInterfaceInstance::Foo(PredicateID);
var x1 = FooInstance1::x;
var x2 = FooInstance2::x;
the variables x1
and x2
may not be the same since they may refer to two different values of x
in the solution (if Foo
is solved multiple times).
Accessing Public Decision Variables From a Sibling Contract
Public decision variables, being public, are also visible from predicates in the same contract as the predicate that declares them. While working within a single contract, you may want one predicate to access another predicate's public decision variables. This can be done, again, using predicate instances. The difference here is that, because we're working in the same contract, there is no need to declare an interface for that contract and an interface instance. There is also no need to specify an address for the predicate instance because the compiler can figure that out on its own! Here's an example:
predicate A {
pub var x: int;
}
predicate B {
predicate AI1 = A();
predicate AI2 = A();
constraint AI1::x == AI2::x * 2;
}
Here, AI1
and AI2
are two instances of predicate A
which exposes a single public decision
variable called x
. We are then requiring that the value of x
in the first instance is double the
value of x
in the second instance.
One important caveat of the above is that you are not allowed to have cyclical dependencies between predicates. For example:
predicate A {
pub var x: int;
predicate BI = B();
constraint BI::y == 69;
}
predicate B {
pub var y: int;
predicate AI = A();
constraint AI::x == 42;
}
Here, predicate A
requires predicate B
and predicate B
requires predicate A
. This causes a
cycle where the addresses 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
╭─[ch_7_1_b.pnt:3:1]
│
3 │ predicate A {
│ ─────┬─────
│ ╰─────── this predicate is on the dependency cycle
│
10 │ predicate B {
│ ─────┬─────
│ ╰─────── 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 {
pub var x: int;
predicate CI = C();
constraint CI::x == 69;
}
The compiler will complain as follows:
Error: self referential predicate `C`
╭─[ch_7_1_c.pnt:6:5]
│
6 │ predicate CI = C();
│ ─────────┬────────
│ ╰────────── this predicate instance 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 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:
var x: int;
@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:
var x: int;
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, $b, $ty, $op) {
var $a: $ty;
var $b: $ty;
constraint $b $op $a;
}
The author of this macro likely expects:
$a
and$b
to be identifiers.$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; x2; int; >);
the compiler will expand the macro call to:
var x1: int;
var x2: int;
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 var
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:
var e: int;
var f: int;
var q: int = @quotion(e; f);
As a result, the compiler will expand the macro call to:
var e: int;
var f: int;
constraint f > 0;
var 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 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 decision variables. When that macro is called multiple times with different arguments, the
resulting var
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) {
var half: int;
constraint $a == half * 2;
}
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 @var_decls($a) {
var foo: int; // Hygienic anonymous binding for `foo`.
var $a: bool; // 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:
var anon_0::foo: int;
var foo: int;
And even when called multiple times with different arguments there would be no error:
@var_decls(foo);
@var_decls(bar);
Becomes equivalent to:
var anon_0::foo: int;
var foo: int;
var anon_1::foo: int;
var bar: int;
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 variables together in relative constraints:
macro @chain($a, &rest) {
// Add the first link in the chain, then move to the rest.
var $a: int;
@chain_next($a; &rest)
}
macro @chain_next($prev, $next, &rest) {
// Add the next link, constrain based on the previous link and continue.
var $next: int;
constraint $next > $prev + 10;
@chain_next($next; &rest)
}
macro @chain_next($prev, $last) {
// Just expand to the final link.
var $last: int;
constraint $last > $prev + 10;
$last
}
When called as var r = @chain(m; n; p)
, the following code would be produced:
var m: int;
var n: int;
constraint n > m + 10;
var p: int;
constraint p > n + 10;
var r = p;
Array Argument Splicing
An extension to macro argument packing the 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:
var array: int[4];
var 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:
var 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
var 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:
var two: int[2];
@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,
var nums = [1, 2, 3];
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.
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.
-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 interfacemacro
- define a macromut
- allows a storage location to be mutablenil
- an empty storage valuepredicate
- define a predicatepub
-self
- used inuse
statementsstate
- bind a state variablestorage
- declare a storage blocktrue
-type
- define a new typeuse
- bring symbols into scopevar
- bind a decision variablewhere
- 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:
__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.
__this_pathway() -> int
Description: Returns the "pathway" of this predicate. The pathway of a predicate is the index of the solution data currently being used to check the predicate.
__predicate_at(pathway: int) -> { b256, b256 }
Description: Returns the full address of predicate at pathway <pathway>
. The pathway of a
predicate is the index of the solution data currently being used to check the predicate. The full
address contains both the content hash of the contract to which the predicate belongs and the
content hash of the predicate itself
__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.
__sha256(data: _) -> b256
Description: Returns a SHA 256 hash from the specified data.
__state_len(data: _) -> int
Description: Returns the length of a state variable. the argument data
must be a state
variable or a "next state" expression but can have any type.
__vec_len(vec: _[]) -> int
Description: Returns the length of a storage vector.
__verify_ed25519(data: _, sig: { b256, b256 }, pub_key: b256) -> bool
Description: Validate an Ed25519 signature against a public key.
__recover_secp256k1(data_hash: b256, sig: { b256, b256, int }) -> { b256, int }
Description: Recover the public key from a secp256k1 signature.
Appendix C: Application Binary Interface (ABI) Spec
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 data such as its storage variables, its predicates, its decision variables, and so on. 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.
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."vars"
: an array that contains every private (i.e. non-pub
) decision variable 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 decision variable."ty"
: a JSON object representing the type of the decision variable. This is further explained in JSON Representation of Types.
"pub_vars"
: an array that contains every public decision variable 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 public decision variable."ty"
: a JSON object representing the type of the public decision variable. This is further explained in JSON Representation of Types.
Note: The order in which private decision variables 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"
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 tuple field has no name.
<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:
storage {
s0: b256,
s1: { int, int },
my_map: ( int => { int, int } ),
}
predicate Foo {
var v0: int;
var v1: bool[5];
pub var t0: { int, int }[5];
pub var t1: b256[3];
}
{
"predicates": [
{
"name": "::Foo",
"vars": [
{
"name": "::v0",
"ty": "Int"
},
{
"name": "::v1",
"ty": {
"Array": {
"ty": "Bool",
"size": 5
}
}
}
],
"pub_vars": [
{
"name": "::t0",
"ty": {
"Array": {
"ty": {
"Tuple": [
{
"name": null,
"ty": "Int"
},
{
"name": null,
"ty": "Int"
}
]
},
"size": 5
}
}
},
{
"name": "::t1",
"ty": {
"Array": {
"ty": "B256",
"size": 3
}
}
}
]
}
],
"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"
}
]
}
}
}
}
]
}
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 two private decision variables:- At index 0, we have
::v0
of typeint
. - At index 1, we have
::v1
of typebool[5]
.
- At index 0, we have
- Predicate
::Foo
has two public decision variables:- The first is called
::t0
. Its an array of 5 tuples, where each tuple contains twoint
s with no field names. - The second is called
::t1
and is an array of 3b256
s.
- The first is called
- The contract also has three 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 first is called