Intro

Zinc is a programming language which can be used to develop:

  1. Smart contracts for zkSync (a ZK Rollup on Ethereum).
  2. General-purpose zero-knowledge proof circuits.

Existing ZKP frameworks lack functionality specific for smart contracts. Security and safety aspects are crucial for developing smart contracts since they deal with valuable financial assets. Modern smart contract languages, such as Simplicity or Libra's Move, deliberately made design choices that favor safety and formal verifiability of the code over generalistic expressiveness.

Zinc is created to fill the gap between the two worlds: to provide a smart contract language optimized for ZKP circuits, which is reliable and simple at the same time, and can be quickly learned by a large number of software developers.

We decided to borrow the Rust syntax and semantics. Zinc is a subset of Rust with minor differences dictated by the subtleties of ZKP circuits. It is easily learnable by any developer familiar with Rust, Golang, C++ or other C-like languages. Also, experience with Solidity will help in understanding some smart contract specifics.

The language is under heavy development, thus many of its aspects will eventually be improved or changed. However, the basic principles, such as security and simplicity, will never be questioned.

Getting help

You can ask questions and get assistance in our Gitter chat room.

If you would like to migrate an existing project to zkSync and require help, please send us an email at [email protected].

Design background

The goal of Zinc is to make writing safe zero-knowledge circuits and ZKP-based smart contracts easy. It has been designed with the following principles in mind:

  • Security. It should be easy to write deterministic and secure applications. Conversely, it should be hard to write code to exploit some possible vulnerabilities found in other programming languages.
  • Safety. The language must enforce the strictest semantics available, such as a strong static explicit type system.
  • Efficiency. The code should compile to the most efficient circuit possible.
  • Cost-exposition. Performance costs that cannot be optimized efficiently must be made explicit to the developers. An example is the requirement to explicitly specify the loop range with constants.
  • Simplicity. Anyone familiar with C-like languages (Javascript, Java, Golang, C++, Rust, Solidity, Move) should be able to learn Zinc quickly and with minimum effort.
  • Readability. The code in Zinc should be easily readable to anybody familiar with the C++ language family. There should be no counter-intuitive concepts.
  • Minimalism. Less code is better. There should ideally be only one way to do something efficiently. Complexity should be reduced.
  • Expressiveness. The language should be powerful enough to make building complex applications easy.
  • Turing incompleteness. Unbounded looping and recursion are not permitted in Zinc. This not only allows more efficient R1CS circuit construction but also makes formal verifiability about the call and stack safety easier and eliminates the gas computation problem inherent to Turing-complete smart contract platforms, such as EVM.

Key features

  • Type safety
  • Type inference
  • Immutability
  • Movable resources as a first-class citizen
  • Module definition and import
  • Expressive syntax
  • Industrial-grade compiler optimizations
  • Turing incompleteness: no recursion or unbounded looping
  • Flat learning curve for Rust/JS/Solidity/C++ developers

Comparison to Rust

Zinc is designed specifically for ZK-circuits and ZKP-based smart contract development, so some differences from Rust are inevitable.

Type system

We need to adapt the type system to be efficiently representable in finite fields, which are the basic building block of R1CS. The current type system mostly follows Rust, but some aspects are borrowed from smart contract languages. For example, Zinc provides integer types with 1-byte step sizes, like those in Solidity.

Ownership and borrowing

Memory management is very different in R1CS circuits compared to the von Neumann architecture. Also, since R1CS does not imply parallel programming patterns, a lot of elements of the Rust design would be unnecessary and redundant. Zinc has no ownership mechanism found in Rust because all variables will be passed by value. The borrowing mechanism is still being designed, but probably, only immutable references will be allowed in the future.

Loops and recursion

Zinc is a Turing-incomplete language, as it does not allow recursion and variable loop indexes. Every loop range must be bounded with constant literals or expressions.

Getting started

Installation

To start using the Zinc framework, do the following:

  1. Download the latest release for your OS and architecture.
  2. Add the folder with the binaries to PATH.
  3. Use the binaries via your favorite terminal.

The minimal Zinc framework consists of the three tools:

  • zargo package manager
  • znc Zinc compiler
  • zvm Zinc virtual machine

zargo can use the compiler and virtual machine through as subprocesses, so you will only need zargo to work with your projects.

For more information on zargo, check out this chapter.

The Visual Studio Code extension

There is a syntax highlighting extension for Zinc called Zinc Syntax Highligthing. The IDE should recommend installing it once you have opened a Zinc file!

The require function

This function creates a custom constraint in any place of your code. Using require() you can check whether some condition is true and make the application exit with an error if otherwise.

The full function description is here.

Example

const BAD_VALUE: u8 = 42;

fn wrong(a: u8, b: u8) -> u8 {
    let c = a + b - BAD_VALUE;
    require(a + b == c, "always fails");
    c
}

Standard library

The standard library is currently located in a built-in module called std. The library contains the following modules:

  • crypto - cryptographic and hash functions
    • ecc - elliptic curve cryptography
    • schnorr - EDDSA signature verification
  • convert - bit array conversion functions
  • array - array processing functions
  • ff - finite field functions
  • collections - data collection types

All the standard library contents are listed in the Appendix E.

Standard library items can be used directly or imported with use:

use std::crypto::sha256; // an import

fn main(preimage: [bool; 256]) -> ([bool; 256], (field, field)) {
    let input_sha256 = sha256(preimage); // imported
    let input_pedersen = std::crypto::pedersen(preimage); // directly

    (input_sha256, input_pedersen)
}

The zkSync library

The zkSync library is an emerging library, which for now only contains the global transaction msg variable:

let amount = zksync::msg.amount;

The zkSync library contents are listed in the Appendix F.

Debugging

There is a special dbg! function, which can print any data anywhere in your code. The function prints data to the terminal and is used only for debugging purposes.

The first argument is the format string, where each {} placeholder is replaced with a corresponding value from the rest of the arguments. The number of placeholders must be equal to the number of the arguments not including the format string.

The full function description is here.

Example

// a = 5, b = 3
fn print_sum(a: u8, b: u8) {
    dbg!("{} + {} = {}", a, b, a + b); // prints '5 + 3 = 8'
}

Testing

The Zinc framework provides some basic unit testing functionality.

Unit tests are just simple functions marked with the #[test] attribute. Such functions may be declared anywhere in the root scope of any module.

A test function can also be marked with other special attributes:

  • #[should_panic] such test must fail in order to succeed, e.g. by passing a false value to the require function or causing an overflow.

  • #[ignore] such test is just ignored.

Examples

#[test]
fn ordinar() {
    require(2 + 2 == 4, "The laws of the Universe have been broken");
}

#[test]
#[should_panic]
fn panicking() {
    require(2 + 2 == 5, "And it's okay");
}

#[test]
#[ignore]
fn ignored() {
    require(2 + 2 > 4, "So we'll just ignore it");
}

Variables and types

This chapter describes the Zinc language concepts. Here you will learn about variables, types, and functions.

Variables

As it was said before, Zinc is mostly about safety and security. Thus, variables are immutable by default. If you are going to change their values, you must explicitly mark them as mutable. It protects your data from accidental mutating where the compiler is unable to check your intentions.

fn test() {
    let x = 0;    
    x = 42; // compile error: mutating an immutable variable

    let mut y = 0;
    y = 42; // ok
}

If you are familiar with Rust, you will not have any trouble understanding this concept, since the syntax and semantics are almost identical. However, pattern matching and destructuring are not implemented yet.

Immutable variables are similar to constants. Like with constants, you cannot change the immutable variable value. However, constants cannot infer their type and you must specify it explicitly.

In contrast to Rust, variables can only be declared in functions. If you need a global variable, you should declare a constant instead. This limitation is devised to prevent unwanted side effects, polluting the global namespace, and bad code design.

const VALUE: field = 0;

fn test() {
    let variable = VALUE;
}

Variable shadowing can be a convenient feature, but Zinc is going to enforce warning-as-error development workflow, forbidding variable shadowing as a potentially unsafe trick. You should use mutable variables or type suffixes if you need several adjacent variables with similar logical meaning.

fn test() {
    let mut x = 5;
    {        
        let x = 25; // compile error: redeclared variable 'x'
    };    
    let x = 25; // compile error: redeclared variable 'x'

    x = 25; // ok
}

Tuple destructuring

It is possible to declare multiple variables with a single let statement:

fn main() {
    let (mut a, b) = (42, 25);

    let (c, (mut d, e)) = (42, (25, 16));
}

This feature is identical to that of Rust, but it is only supported for the let statement. Function arguments cannot be destructured.

Types

Zinc is a statically typed language, thus all the variables must have a type known at the compile-time. Strict type system allows to catch the majority of runtime errors, which are very common to dynamically typed languages.

If you are familiar with Rust, you will find the Zinc type system very similar, but with some modifications, limitations, and restrictions.

Types are divided into several groups:

To read more about casting, conversions, and type policy, go to this chapter.

You can declare type aliases in Zinc, which allow you to shorten type signatures of complex types by giving them a name:

type ComplexType = [(u8, [bool; 8], field); 16];

fn example(data: ComplexType) {}

Scalar types

Scalar types are also called primitive types and contain a single value.

Unit

The unit type and value are described with empty round parenthesis (). Values of that type are implicitly returned from functions, blocks, and other expressions which do not return a value explicitly. Also, this type can be used as a placeholder for input, witness and output types of the main function.

() is the literal for both unit type and value. The unit type values cannot be used by any operators or casted back and forth.

The unit type can exist as a standalone value:

let x = (); // ()

It is implicitly returned by blocks or functions:

fn check(value: bool) {
    // several statements
};

let y = check(true); // y is ()

Boolean

bool is the boolean type keyword.

Boolean value is represented as field with value set to either 0 or 1. To ensure type safety casting between boolean and integer types is not allowed.

Literals

true and false.

Examples

let a = true;
let b: bool = false;

if a && !b {
    debug(a ^^ b);
};

Integer

Integer types can be of any size between 1 and 32 bytes. This feature was borrowed from Solidity and it helps to reduce the number of constraints and smart contract size. Internal integer representation uses the BN256 field of different bitlength.

Types

  • u8 .. u248: unsigned integers
  • i8 .. i248: signed integers
  • field: the native field integer

Integer types bitlength step equals 8, that is, only the following bitlengths are possible: 8, 16, ..., 240, 248.

A field value is a native field element of the elliptic curve used in the constraint system. It represents an unsigned integer of bitlength equal to the field modulus length (e.g. for BN256 the field modulus length is 254 bit).

All the types are represented using field as their basic building block. When an integer variable is allocated, its bitlength must be enforced in the constraint system.

Literals

  • decimal: 0, 1, 122, 574839572494237242
  • hexadecimal: 0x0, 0xfa, 0x0001, 0x1fffDEADffffffffffBEEFffff

Only unsigned integer literals can be expressed, since the unary minus is not a part of the literal but a standalone operator. Thus, unsigned values can be implicitly casted to signed ones using the unary minus.

Casting

Casting can be done only between integer and field types. If the value does not fit into the target type, it is truncated.

Inference

If the literal type is not specified, the minimal possible bitlength is inferred.

Examples

let a = 0; // u8
let a: i24 = 0; // i24
let b = 256; // u16
let c = -1;  // i8
let c = -129; // i16
let d = 0xff as field; // field
let e: field = 0; // field

Arrays

Arrays are collections of values of the same type sequentially stored in the memory.

Arrays support the index and slice operators, which is explained in detail here.

let mut fibbonaci = [0, 1, 1, 2, 3, 5, 8, 13];
let element = fibbonaci[3];
fibbonaci[2] = 1;

There is a minor restriction for arrays at the current language state. Arrays cannot be indexed with a witness value, but only with a constant or witness-independent variable.

Tuples

Tuples are anonymous collections of values of different types, sequentially stored in memory and gathered together due to some logical relations.

Tuple fields can be accessed via the dot operator, which is explained in detail here.

let mut tuple: (u8, field) = (0xff, 0 as field);
tuple.0 = 42;
dbg!("{}", tuple.1);

If you familiar with Rust, you may remember the peculiar connection between unit values, parenthesized expressions, and tuples of one element:

  • () is a unit value
  • (value) is a parenthesized expression
  • (value,) is a tuple of one element

Structures

The structure is a custom data type which lets you name and package together multiple related values that make up a meaningful group. Structures allow you to easily build complex data types and pass them around your code with as little verbosity as possible.

Structure fields can be accessed via the dot operator, which is explained in detail here.

struct Person {
    age: u8,
    id: u64,
}

fn main() {
    let mut person = Person {
        age: 24,
        id: 123456789 as u64,
    };
    person.age = 25;
}

Implementation

A structure can be implemented, that is, some methods and associated items may be declared for it. The structure implementation resemble the behavioral part of a class in object-oriented language.

struct Arithmetic {
    a: field,
    b: field,
}

impl Arithmetic {
    pub fn add(self) -> field {
        self.a + self.b
    }

    pub fn sub(self) -> field {
        self.a - self.b
    }

    pub fn mul(self) -> field {
        self.a * self.b
    }

    pub fn div(self) {
        require(false, "Field division is forbidden!");
    }
}

fn main() {
    let a: field = 10;
    let b: field = 5;
    let arithmetic = Arithmetic { a: a, b: b };
    
    dbg!("{} + {} = {}", a, b, arithmetic.add());
    dbg!("{} - {} = {}", a, b, arithmetic.sub());
    dbg!("{} * {} = {}", a, b, arithmetic.mul());
    dbg!("{} / {} = {}", a, b, arithmetic.div()); // will panic
}

For more information on methods, see this chapter.

Enumerations

These allow you to define a type by enumerating its possible values. Only simple C-like enums are supported for now, which are groups of constants:

enum Order {
    FIRST = 0,
    SECOND = 1,
}

Enum values can be used with match expressions to define the behavior in every possible case:

let value = Order::FIRST;
let result = match value {
    Order::FIRST => do_this(),
    Order::SECOND => do_that(),
};

The enum values can be implicitly casted to integers using let statements or explicitly via the as operator:

let x = Order::FIRST; // the type is Order (inference)
let y: u8 = Order::SECOND; // the type is u8 (implicit casting)
let z = Order::SECOND as u8; // the type is u8 (explicit casting)

Implementation

An enumeration can be implemented, that is, some methods and associated items may be declared for it. The enumeration implementation resemble the behavioral part of a class in object-oriented language.

enum List {
    First = 1,
    Second = 2,
    Third = 3,
}

impl List {
    pub fn first() -> Self {
        Self::First
    }

    pub fn second() -> Self {
        Self::Second
    }

    pub fn third() -> Self {
        Self::Third
    }
}

fn main(witness: field) -> field {
    (List::first() + List::second() + List::third()) as field * witness
}

For more information on methods, see this chapter.

Strings

For now, strings have very limited implementation and usability.

The string values may exist only in the literal form and can only appear in the dbg and require intrinsic functions:

dbg!("{}", 42); // format string

require(true != false, "a very obvious fact"); // optional error message

Casting and conversions

The language enforces static strong explicit type semantics. It is the strictest type system available since reliability is above everything. However, some inference abilities will not do any harm, so you do not have to specify types in places where they are highly obvious.

Explicit

Type conversions can be only performed on the integer and enumeration types with the casting operator. This chapter explains the operator's behavior in detail.

Implicit

The let statement can perform implicit type casting of integers if the type is specified to the left of the assignment symbol. Let us examine the statement:

let a: field = 42 as u32;
  1. 42 is inferred as a value of type u8.
  2. 42 is cast from u8 to u32.
  3. The expression 42 as u32 result is cast to field.
  4. The field value is assigned to the variable a.

The second case of implicit casting is the negation operator, which always returns a signed integer type value of the same bitlength, regardless of the input argument.

let positive = 100; // u8
let negative = -positive; // i8

This chapter describes the negation operator in more detail.

Inference

For now, Zinc infers types in two cases: integer literals and let bindings.

Integer literals are always inferred as values of the minimal possible size. That is, 255 is a u8 value, whereas 256 is a u16 value.

The let statement can infer types in case its type is not specified.

let value = 0xffffffff_ffffffff_ffffffff_ffffffff;

In the example above, the value variable gets type u128, since 128 bytes are enough to represent the value 0xffffffff_ffffffff_ffffffff_ffffffff;

Maps

The std::collections::MTreeMap is a special type, which can only be used as a smart contract storage field:

use std::collections::MTreeMap;

struct Data {
    a: u8,
    b: u8,
}

contract Test {
    owner: u160;
    data: MTreeMap<u8, Data>;
    
    pub fn new(owner: u160) -> Self {
        Self {
            owner: owner,
            data: MTreeMap,
        }
    }

    pub fn example(mut self) {
        let (old1, existed1) = self.data.insert(42, Data { a: 16, b: 9 });
        let (value, exists1) = self.data.get(42);
        let exists2 = self.data.contains(42);
        let (old2, existed2) = self.data.remove(42);
    }
}

The maps introduce a new concept of generic types, but this feature can only be used to specify the key and value types for the MTreeMap instance.

The full description of the MTreeMap methods is here.

Initialization

To initialize a map, just use the type name as the value. The syntax may change to something more intuitive in the future.

use std::collections::MTreeMap;

contract Test {
    data: MTreeMap<u8, field>;
    
    pub fn new(owner: u160) -> Self {
        Self {
            data: MTreeMap,
        }
    }
}

Function

The function is the only callable type in Zinc. However, R1CS specifics require that functions must be executed completely, thus there is no return statement. The only way to return a value is to specify it as the last unterminated statement of the function block.

Functions consist of several parts: the name, arguments, return type, and the code block. The function name uniquely defines the function within its namespace. The arguments can be only passed by value, and the function result can only be returned by value. If the return type is omitted, the function is considered returning a unit value (). The code block can access the global scope, but it has no information about where the function has been called from.

const GLOBAL: u8 = 31;

fn wierd_sum(a: u8, b: u8) -> u8 {
    dbg!("{} + {}", a, b);
    a + b + GLOBAL // return value
}

fn main() {
    let result = wierd_sum(42, 27);
    require(result == 100, "the weird sum is incorrect");
}

Methods

Methods are functions declared in a structure or enumeration implementation, or in a smart contract definition. Such functions accept the object instance as the first argument and can be called via the dot operator.

struct Data {
    a: u8,
    b: u8,
    c: u8,
    d: u8,
}

impl Data {
    pub fn sum(self) -> u8 {
        self.a + self.b + self.c + self.d
    }
}

fn main() {
    let data = Data { a: 1, b: 2, c: 3, d: 4 };
    
    dbg!("Data sum is: {}", data.sum());
}

Methods can be called like ordinary functions using the type namespace they are declared in. In some languages it is called a static form:

dbg!("Data sum is: {}", Data::sum(data));

If the first argument of a method is mutable, the method is considered mutable and it can alter the instance field values. Also, a mutable method can only be called from another mutable method, providing some extra data safety.

struct Data {
    a: u8,
    b: u8,
}

impl Data {
    pub fn double(mut self) -> Self {
        self.a *= 2;
        self.b *= 2;
        self
    }
}

fn main() {
    let mut data = Data { a: 2, b: 1 };
    dbg!("Data x1 is: {}", data);

    let data_x2 = data.double();
    dbg!("Data x2 is: {}", data_x2);
}

Constant functions

Constant functions are called at compile-time, thus they may only accept and return constant expressions. Such functions are useful when you need to use a lot of similar parameterized values, and you are not willing to repeat the calculating code each time.

const fn cube(x: u64) -> u64 { x * x * x }

fn main() {
    let cubed_ten = cube(10 as u64); // 1000
    let cubed_twenty = cube(20 as u64); // 8000
}

Such functions only exist at compile time, so they do not impact the application performance at all.

Operators

Operators of the Zinc language can be divided into several groups:

Precedence

The top one is executed first.

OperatorAssociativity
::left to right
[] .left to right
- ~ !unary
asleft to right
* / %left to right
+ -left to right
<< >>left to right
&left to right
^left to right
left to right
== != <= >= < >require parentheses
&&left to right
^^left to right
⎮⎮left to right
.. ..=require parentheses
= += -= *= /= %= ⎮= ^= &= <<= >>=require parentheses

Arithmetic operators

Arithmetic operators do not perform any kind of overflow checking at compile-time. If an overflow happens, the Zinc VM will fail at runtime.

When it comes to the division of negative numbers, Zinc follows the Euclidean division concept. It means that -45 % 7 == 4. To get the detailed explanation and some examples, see the article.

The +=, -=, *=, /=, %= shortcut operators perform the operation and assign the result to the first operand. The first operand must be a mutable memory location like a variable, array element, or structure field.

Addition

+ and += are binary operators.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Subtraction

- and -= are binary operators.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Multiplication

* and *= are binary operators.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Division

/ and /= are binary operators.

Accepts

  1. Integer expression (any type except field)
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Remainder

% and %= are binary operators.

Accepts

  1. Integer expression (any type except field)
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Negation

- is an unary operator.

Accepts

  1. Unsigned integer expression

Returns an integer result of the same type.

Bitwise operators

The |=, ^=, &=, <<=, >>= shortcut operators perform the operation and assign the result to the first operand. The first operand must be a mutable memory location like a variable, array element, or structure field.

Bitwise OR

| and |= are binary operators.

Accepts

  1. Unsigned integer expression (excluding field)
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Bitwise XOR

^ and ^= are binary operators.

Accepts

  1. Unsigned integer expression (excluding field)
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Bitwise AND

& and &= are binary operators.

Accepts

  1. Unsigned integer expression (excluding field)
  2. Expression of the operand 1 type

Returns an integer result of the same type.

Bitwise shift left

<< and <<= are binary operators.

Accepts

  1. Unsigned integer expression (excluding field)
  2. Constant unsigned integer expression

Returns an integer result of the operand 1 type.

Bitwise shift right

>> and >>= are binary operators.

Accepts

  1. Unsigned integer expression (excluding field)
  2. Constant unsigned integer expression

Returns an integer result of the operand 1 type.

Bitwise NOT

~ is an unary operator.

Accepts

  1. Unsigned integer expression (excluding field)

Returns an integer result.

Comparison operators

Equality

== is a binary operator.

Accepts

  1. Integer or boolean expression
  2. Expression of the operand 1 type

Returns the boolean result.

Non-equality

!= is a binary operator.

Accepts

  1. Integer or boolean expression
  2. Expression of the operand 1 type

Returns the boolean result.

Lesser or equals

<= is a binary operator.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns the boolean result.

Greater or equals

>= is a binary operator.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns the boolean result.

Lesser

< is a binary operator.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns the boolean result.

Greater

> is a binary operator.

Accepts

  1. Integer expression
  2. Expression of the operand 1 type

Returns the boolean result.

Logical operators

OR

|| is a binary operator.

Accepts

  1. Boolean expression
  2. Boolean expression

Returns the boolean result.

XOR

^^ is a binary operator.

Accepts

  1. Boolean expression
  2. Boolean expression

Returns the boolean result.

AND

&& is a binary operator.

Accepts

  1. Boolean expression
  2. Boolean expression

Returns the boolean result.

NOT

! is a unary operator.

Accepts

  1. Boolean expression

Returns the boolean result.

Casting operator

as is a binary operator.

Accepts

  1. Expression of any type
  2. Expression of the same or another integer type

Returns the casted value.

Casting allowed:

  • from integer to integer
  • from enum to integer
  • to the same type (no effect, no errors)
enum Order {
    First = 1,
}

let a = 1; // inferred as u8
let b = a as i8; // explicit casting to the opposite sign
let c: u8 = Order::First; // implicit casting to an integer

Access operators

Path resolution

:: is a binary operator.

Accepts

  1. Namespace identifier (module, structure, enumeration)
  2. Item identifier (module, type, variable, constant etc.)

Returns the second operand.

Array indexing

[] is a binary operator.

Accepts

  1. Array expression
  2. Integer or range expression

Returns an array element (if the 2nd operand is an integer) or a sub-array (if the 2nd operand is a range).

Field access

. is a binary operator.

Accepts

  1. Tuple or structure/contract expression
  2. Tuple index or structure/contract member name

Returns a tuple element or structure/contract member.

Range operators

Range

.. is a binary operator.

Range operator is used only for loop bounds or array slicing.

The operator can accept operands of different integer types. The result will be signed if any of the operands if signed, and the bitlength will be enough to contain the greater range bound.

Accepts

  1. Constant integer expression
  2. Constant integer expression

Returns a temporary range element to be used as a slice or loop range.

Inclusive range

..= is a binary operator.

The same as the above, but the right range bound is inclusive.

Accepts

  1. Constant integer expression
  2. Constant integer expression

Returns a temporary range element to be used as a slice or loop range.

Assignment operators

= is a binary operator.

Accepts

  1. Place expression (a descriptor of a memory place, e.g. a variable, array element, etc.)
  2. Expression of the operand 1 type

Returns ().

There are several variations of the assignment operator performing an operation before the assignment: |=, ^=, &=, <<=, >>=, +=, -=, *=, /=, %=.

Expressions

Expressions consist of operands and operators.

Operators have already been described in this chapter.

Zinc supports constant expressions with arrays, tuples, structures, conditionals, and matches, which are described here.

Operands

Any syntax constructions computed into values can be used in expressions. Zinc does all the type checking at compile-time, so you can build expressions of arbitrary complexity without caring about type safety. However, you should care about readability and maintainability, since there are probably other people going to work with your code.

Literals

Simple literal operands are the basic elements of an expression:

  • 42 - integer
  • false - boolean
  • "error" - string
  • u128 - type (in casting clauses like 42 as u128)

There are several complex operands worth mentioning. As you will see from the examples, you can nest these constructions as deep as you need, but do not abuse this ability too much.

Integer literal can be written with the pseudo-fractional part, which is useful for representing values with a lot of zeros after the comma, e.g. WEI units or satoshis: 1.0_E18, 0.001_E8, 42_E6.

Such numbers are pseudo-fractional, as the exponent cannot be less than the number of fractional digits.

Array

let array = [
    1,
    2,
    3,
    4,
    5,
    1 + 5,
    { let t = 5; t * t },
];

The inner type and array length are inferred by the compiler.

Tuple

let tuple = (42, true, [1, 2, 3]);

The inner types and the tuple type are inferred by the compiler.

Structure

struct Data {
    value: field,
}

fn main() {
    let data = Data {
        value: 0,
    };
}

Blocks

A block expression consists of zero or more statements and an optional result expression. Every block starts a new scope of visibility.

let c = {
    let a = 5;
    let b = 10;
    a + b
};

Conditionals

if

An if conditional expression consists of the condition, main block, and optional else block. Every block starts a new scope of visibility.

let condition = true;
let c = if condition {
    let a = 5;
    a
} else {
    let b = 10;
    b
};

match

The match expression is a syntactic sugar for nested conditional expressions. Each branch block starts a new scope of visibility.

enum MyEnum {
    ValueOne = 1,
    // ...
    ValueTen = 10,
}

fn main() {
    let value = MyEnum::ValueOne;

    match value {
        MyEnum::ValueOne => { /* ... */ },
        MyEnum::ValueTen => { /* ... */ },
    }
}

For now, only the following match patterns are supported:

  • constant (e.g. 42)
  • path (e.g. MyEnum::ValueOne)
  • variable binding (e.g. value)
  • wildcard (_)

Only simple types can be used as the match scrutinee for now, that is, you cannot match an array, tuple, or structure.

Constant expressions

A constant expression is evaluated at compile time. It is useful to declare some global data used throughout the project.

const UNIX_EPOCH_TIMESTAMP: (u64, u8, u8) = (1970, 1, 1);

Statements

The basic element of a Zinc application is a statement.

Statements are divided into several types:

  1. Declaration statements
  2. Expression statements
  3. Control statements

Declaration statements

The declaration statements declare a new item, that is, a type, variable or module.

let variable declaration

let [mut] {identifier}[: {type}] = {expression};

The let declaration behaves just like in Rust, but it does not allow uninitialized variables.

The type is optional and is used mostly to cast integer literal or double-check the expression result type, otherwise, it is inferred.

let mut variable: field = 0;

type alias declaration

type {identifier} = {type};

The type statement declares a type alias to avoid repeating complex types.

type Alias = (field, u8, [field; 8]);

struct type declaration

The struct statement declares a structure.

struct Data {
    a: field,
    b: u8,
    c: (),
}

enum type declaration

The enum statement declares an enumeration.

enum List {
    A = 1,
    B = 2,
    C = 3,
}

fn type declaration

The fn statement declares a function.

fn sum(a: u8, b: u8) -> u8 {
    a + b
}

impl namespace declaration

The impl statement declares a namespace of a structure or enumeration.

struct Data {
    value: field,
}

impl Data {
    fn print(self) {
        dbg!("{}", data.value);
    }
}

mod module declaration

mod {identifier};

The mod statement declares a new module and requires an eponymous module file to be present in the declaring module directory.

That is, if your declare a module named utils in the file main.zn located in the src/ directory, there must be a file src/utils.zn.

The Zinc module system almost completely mimics that of Rust, but requires every module to reside in a separate file and temporarily allows importing private items.

use module import

use {path};

The use statement imports an item from another namespace to the current one.

Using the example above, you may import items from your utils module this way:

mod utils;

use utils::UsefulUtility;

// some code using 'UsefulUtility'

contract declaration

The contract statement declares a smart contract. Contracts are described here. The statement is a merged struct and impl statements, but it can be only declared in the entry point file.

type Currency = u248;
type PairToken = u8;

contract Uniswap {
    // The contract storage fields     
    balance_1: Currency;
    balance_2: Currency;    
    rate: u248;
    
    // Public entries available from outside   
    
    pub fn deposit(self, amount: Currency, token: PairToken) {
        // ...
    }

    pub fn withdraw(self, amount: Currency) {
        // ...
    }
    
    pub fn buy(self, amount: Currency, from: PairToken) {
        // ...
    }
    
    // Private functions
    
    fn foo(self) {
        // ...
    }
}

Expression statements

Expression

The expression statement is an expression terminated with a ; to ignore its result. The most common use is the assignment to a mutable variable:

let mut a = 0;
a = 42; // an expression statement ignoring the '()' result of the assignment

For more information on expressions, check this chapter.

Semicolons

Expression statements in Zinc must be always terminated with ; to get rid of some ambiguities regarding block and conditional expressions.

Control statements

Control statements neither ignore the result nor declare a new item. The only such statement is the for-while loop.

for-while loop

for {identifier} in {range} [while {expression}] {
    ...
}

The for loop statement can be modified with the while condition, which will be checked before each iteration of the loop. The while condition expression has access to the loop iterator variable.

let x = 7;

for i in 0..10 while i % x != 2 {
    // do something
};

Only constant expressions can be used as the bounds of the iterator range. The while condition will not cause an early return, but it will suppress the loop body side effects.

Zinc is a Turing-incomplete language, as it is dictated by R1CS restrictions, so loops always have a fixed number of iterations. On the one hand, the loop counter can be optimized to be treated as a constant, reducing the circuit cost, but on the other hand, you cannot force a loop to return early, increasing the circuit cost.

if and match

The conditional and match expressions can act as control statements, ignoring the returned value. To use them in such a role, just terminate the expression with a semicolon:

fn unknown(value: u8) -> u8 {
    match value {
        1 => dbg!("One!"),
        2 => dbg!("Two!"),
        _ => dbg!("Perhaps, three!"),
    };
    42
}

Smart contracts

A Zinc smart contract consists of the entry file main.zn, where the contract itself is declared, and zero or more modules, whose contents can be imported into the main file.

Example

Entry point file

/// 
/// 'src/cube_deposit.zn'
///
/// Triples the deposited amount.
///

mod simple_math;

use simple_math::cube;

contract CubeDeposit {
    pub balance: u64;

    pub fn deposit(mut self, amount: u64) {
        self.balance += cube(amount);
    }
}

Module simple_math file

/// 
/// 'src/simple_math.zn'
/// 

/// Returns x^3.
fn cube(x: u64) -> u64 {
    x * x * x
}

Storage and methods

A typical contract consists of several groups of entities:

  • implicit storage fields
  • explicit storage fields
  • the constructor
  • public methods
  • private methods
  • built-in methods
  • global variables
  • constants

Implicit storage fields

There are several implicitly created fields, which are set upon the contract publishing:

  • the contract address (field address of type u160)
  • the contract balances (field balances of type std::collections::MTreeMap<u160, u248>), where the key is a zkSync token address, and the value is token amount.

So, when you see an empty contract contract Empty {}, it actually looks like this:

// will not compile, because the fields are already there!
contract Empty {
    pub address: u160;

    pub balances: std::collections::MTreeMap<u160, u248>;
}

The public (pub) fields are visible when querying the contract storage state, whereas the private fields are internal and cannot be seen.

Explicit storage fields

The explicit storage fields are declared in the same way as in structure, but with a semicolon in the end.

contract Example {
    pub tokens: (u8, u64);

    data: [u8; 1000];

    //...
}

Each smart contract instance gets its own storage, which is written to the persistent databases by the Zinc Zandbox server.

The constructor

Each contract must have a constructor, a special function with the name new, which returns a Self contract instance. The contract instance is not a value, but a reference to it, that is, its ETH address of type u160.

You must not initialize the implicit storage fields, since they are filled automatically.

contract Example {
    pub value: u64;

    pub fn new(_value: u64) -> Self {
        Self {
            value: _value,
        }
    }
}

Public methods

The contract declaration contains several public functions, which serve as contract methods. The contract must have at least one public function.

contract Example {
    //...

    pub fn deposit(mut self, amount: u64) -> bool { ... }
}

Private methods

The private functions are declared without the pub keyword and have no special meaning. Such functions are simply associated with the contract and can be called from within the public methods.

contract Example {
    //...

    fn get_balance(address: u160) -> bool { ... }
}

Builtin methods

Each smart contract includes two built-in methods.

The method signatures are described in Appendix D.

Transfer

The transfer method is used to send tokens to another account. The method is mutable, so it can only be called from the mutable context.

contract Example {
    //...

    fn send(mut self, address: u160, amount: u248) -> {
        self.transfer(
            address, // recipient address
            0x0, // zkSync ETH token address
            amount, // amount in wei
        );
    }
}

Fetch

The fetch method is used to load a contract instance from the Zandbox server.

contract Example {
    //...

    pub fn get_balance(self, address: u160, token: u160) -> u248 {
        let instance = AnotherContract::fetch(address);
        let (balance, found) = instance.balances.get(token);
        balance
    }
}

Global variables

Each contract includes the global zksync::msg variable, which contains the transfer data the contract has been called with. The variable description can be found in the Appendix F.

Constants

A contract may contain some constants associated with it. The constants do not have any special meaning and can be used from within the contract functions or from the outside.

contract Example {
    //...

    pub const VERSION: u8 = 1; // public constant 

    const LIMIT: u8 = 255; // private constant
}

Minimal example

In this example we will implement the simplest exchange smart contract, where it will be possible to exchange between a pair of tokens with ever-constant price.

You will need zargo, which is the Zinc package manager, which bundles smart contract projects and simplifies usage of contract methods, using input data JSON template located in the project data directory.

Project initialization

To create a new smart contract project, use the following command:

zargo new --type contract constant_price

Zargo will create a project with some default template code:

//!
//! The 'constant_price' contract entry.
//!

contract ConstantPrice {
    pub value: u64;

    pub fn new(value: u64) -> Self {
        Self {
            value: value,
        }
    }
}

Let's change the code by:

  • removing the redundant value auto-generated field
  • adding the fee parameter
  • adding two mutable methods for making exchanges and deposits
  • adding an immutable method for getting the contract fee value
  • declaring the Address and Balance type aliases
  • declaring the TokenAddress enumeration type with zkSync token addresses

The TokenAddress enumeration lists the token address-like identifiers from the Rinkeby zkSync network. Addresses of tokens on the Rinkeby network should not change and may be taken from here for further usage.

type Address = u160;
type Balance = u248;

enum TokenAddress {
    ETH = 0x0000000000000000000000000000000000000000,
    USDT = 0x3b00ef435fa4fcff5c209a37d1f3dcff37c705ad,
    USDC = 0xeb8f08a975ab53e34d8a0330e0d34de942c95926,
    LINK = 0x4da8d0795830f75be471f072a034d42c369b5d0a,
    TUSD = 0xd2255612f9b045e9c81244bb874abb413ca139a3,
    HT = 0x14700cae8b2943bad34c70bb76ae27ecf5bc5013,
    OMG = 0x2b203de02ad6109521e09985b3af9b8c62541cd6,
    TRB = 0x2655f3a9eeb7f960be83098457144813ffad07a4,
    ZRX = 0xdb7f2b9f6a0cb35fe5d236e5ed871d3ad4184290,
    BAT = 0xd2084ea2ae4bbe1424e4fe3cde25b713632fb988,
    REP = 0x9cac8508b9ff26501439590a24893d80e7e84d21,
    STORJ = 0x8098165d982765097e4aa17138816e5b95f9fdb5,
    NEXO = 0x02d01f0835b7fdfa5d801a8f5f74c37f2bb1ae6a,
    MCO = 0xd93addb2921b8061b697c2ab055979bbefe2b7ac,
    KNC = 0x290eba6ec56ecc9ff81c72e8eccc77d2c2bf63eb,
    LAMB = 0x9ecec4d48efdd96ae377af3ab868f99de865cff8,
    GNT = 0xd94e3dc39d4cad1dad634e7eb585a57a19dc7efe,
    MLTT = 0x690f4886c6911d81beb8130db30c825c27281f22,
    XEM = 0xc3904a7c3a95bc265066bb5bfc4d6664b2174774,
    DAI = 0x2e055eee18284513b993db7568a592679ab13188,
}

impl TokenAddress {
    pub fn is_known(address: Address) -> bool {
        match address {
            0x0000000000000000000000000000000000000000 => true,
            0x3b00ef435fa4fcff5c209a37d1f3dcff37c705ad => true,
            0xeb8f08a975ab53e34d8a0330e0d34de942c95926 => true,
            0x4da8d0795830f75be471f072a034d42c369b5d0a => true,
            0xd2255612f9b045e9c81244bb874abb413ca139a3 => true,
            0x14700cae8b2943bad34c70bb76ae27ecf5bc5013 => true,
            0x2b203de02ad6109521e09985b3af9b8c62541cd6 => true,
            0x2655f3a9eeb7f960be83098457144813ffad07a4 => true,
            0xdb7f2b9f6a0cb35fe5d236e5ed871d3ad4184290 => true,
            0xd2084ea2ae4bbe1424e4fe3cde25b713632fb988 => true,
            0x9cac8508b9ff26501439590a24893d80e7e84d21 => true,
            0x8098165d982765097e4aa17138816e5b95f9fdb5 => true,
            0x02d01f0835b7fdfa5d801a8f5f74c37f2bb1ae6a => true,
            0xd93addb2921b8061b697c2ab055979bbefe2b7ac => true,
            0x290eba6ec56ecc9ff81c72e8eccc77d2c2bf63eb => true,
            0x9ecec4d48efdd96ae377af3ab868f99de865cff8 => true,
            0xd94e3dc39d4cad1dad634e7eb585a57a19dc7efe => true,
            0x690f4886c6911d81beb8130db30c825c27281f22 => true,
            0xc3904a7c3a95bc265066bb5bfc4d6664b2174774 => true,
            0x2e055eee18284513b993db7568a592679ab13188 => true,
            _ => false,
        }
    }
}

contract ConstantPrice {
    const MAX_FEE: u16 = 10000;
    const PRECISION_MUL: Balance = 1E3;

    pub fee: u16;

    pub fn new(_fee: u16) -> Self {
        require(_fee <= Self::MAX_FEE, "The fee value must be between 0 and 10000");

        Self {
            fee: _fee,
        }
    }

    pub fn deposit(mut self) {
        // check if the transaction recipient is the contract address
        require(zksync::msg.recipient == self.address, "The transfer recipient is not the contract");

        // check if the deposited token is known to the contract
        require(TokenAddress::is_known(zksync::msg.token_address), "The deposited token is unknown");

        // check if the deposited amount is not zero
        require(zksync::msg.amount > 0, "Cannot deposit zero tokens");
    }

    pub fn exchange(
        mut self,
        withdraw_token: Address,
    ) {
        // check if the transaction recipient is the contract address
        require(zksync::msg.recipient == self.address, "The transfer recipient is not the contract");

        // check if the deposited token is known to the contract
        require(TokenAddress::is_known(zksync::msg.token_address), "The deposited token is unknown");

        // check if the withdrawn token is known to the contract
        require(TokenAddress::is_known(withdraw_token), "The withdrawn token is unknown");

        // check if the deposited amount is not zero
        require(zksync::msg.amount > 0, "Cannot deposit zero tokens");

        // check if the deposited and withdrawn token identifiers are different
        require(zksync::msg.token_address != withdraw_token, "Cannot withdraw the same token");

        let withdraw_token_amount = zksync::msg.amount *
            ((Self::MAX_FEE - self.fee) as Balance * Self::PRECISION_MUL / Self::MAX_FEE as Balance) /
            Self::PRECISION_MUL;
        // check if there is enough balance to withdraw
        require(self.balances.get(withdraw_token).0 >= withdraw_token_amount, "Not enough tokens to withdraw");

        self.transfer(zksync::msg.sender, withdraw_token, withdraw_token_amount);
    }

    pub fn get_fee(self) -> u16 {
        self.fee
    }
}

In our case, the fee is an integer value between 0 and 10000, where the latter represents 100%. It is common practice to use integer values in this way, since there are usually limited support of floating point numbers in safe smart contract languages. We are also using some additional fractional digits to avoid getting zeros after integer division. That is, instead of doing amount * 9900 / 10000, we do amount * 9900 * 1E3 / 10000 / 1E3.

Publishing the contract

Before publishing, run zargo build in the project directory. Then, open the ./data/input.json constructor input template file and fill the constructor arguments you are going to pass:

{
  "arguments": {
    "new": {
      "_fee": "100"
    }
    // ...
  }
}

Also, put your account private key to the private_key file at the project root. All deposits and transfers to the newly created contract will be done from that account. Ensure that your account is unlocked and has enough balance to pay fees. To see how to unlock a new zkSync account, visit the troubleshooting chapter.

To publish the contract, use this simple command with the network identifier and instance name:

zargo publish --network rinkeby --instance default

Since every follower of this tutorial should have created a contract with the name constant_price, the contract may not be uploaded, as its name, version and instance must be unique. To fix this issue, you may change your contract name and version in the Zargo.toml manifest, and the instance argument in the command line. To see all uploaded projects, use the zargo download --list --network rinkeby command.

When the contract is successfully published, its ETH address and zkSync account ID will be returned. You will need the address to make some further calls. Let's assume it is 0x1234...1234.

The instance name is used to uniquely identify your published contract without memorizing its ETH address.

The contract has been published!

Querying the contract storage

The constant_price contract is now published, and its dedicated storage instance is created. You may query the Zandbox server to see its zero balances:

zargo query --network rinkeby --address 0x1234...1234

Calling a non-mutable contract method

A non-mutable contract method can be called with the same query as above, but with the method argument:

zargo query --network rinkeby --address 0x1234...1234 --method get_fee

The output:

{
  "output": "100"
}

Calling a mutable contract method

Let's now call our contract deposit method!

Open the method input template file ./data/input.json and specify the token identifier and amount you want to exchange:

{
  "msg": {
    "sender": "<your_address>",
    "recipient": "0x1234...1234",
    "token_address": "0x0000000000000000000000000000000000000000", // ETH
    "amount": "0.1_E18"
  }
}

Be cautious when specifying the exponent value for token amounts, as it is crucial to specify the correct number of decimal digits for each token.

To call the contract method, use the following command with the method name and contract account ID:

zargo call --network rinkeby --address 0x1234...1234 --method deposit

After the call has succeeded, query the contract storage again to see the expected result:

{
  "address": "0x1234...1234",
  "balances": [
    {
      "key": "0x0",
      "value": "100000000000000000" // 0.1_E18
    }
  ]
}

Now you may repeat the call for other tokens and when there is more than one token on the exchange, call the exchange method, specifying the token you want to withdraw.

What's next

When you have a new smart contract version, just publish it with another instance name and it will get a separate storage instance, living its own life!

Also, there is a Curve smart contract implementation in Zinc. Check it out!

The Curve

The Curve smart contract has been partially ported from its original Vyper implementation.

The full Zinc source code is here.

Code listings

Here are the most important parts of the Curve implementation. Some boilerplate code with types and constants is omitted and can be checked out via the link above.

Main module

//!
//! The Curve Stableswap contract.
//!

mod types;
mod invariant;
mod constants;
mod exchange;

use self::constants::ZERO;
use self::constants::N;
use self::types::Address;
use self::types::Balance;
use self::types::token_address::TokenAddress;

///
/// The Curve Stableswap contract.
///
contract Stableswap {
    /// The tokens being traded in the pool.
    pub tokens: [TokenAddress; N];

    /// The Curve amplifier.
    pub amplifier: u64;

    ///
    /// The contract constructor.
    ///
    pub fn new(
        _tokens: [TokenAddress; N],
        _amplifier: u64,
    ) -> Self {
        require(_amplifier > 0, "The Curve amplifier cannot be zero");

        Self {
            tokens: _tokens,
            amplifier: _amplifier,
        }
    }

    ///
    /// Adds liquidity to the contract balances.
    ///
    pub fn deposit(mut self) {
        require(
            zksync::msg.recipient == self.address,
            "Transaction recipient is not the contract",
        );

        // panics if the token with address `zksync::msg.token_address` is not traded in this pool
        let deposit_idx = self.token_position(TokenAddress::from_address(zksync::msg.token_address));
    }

    ///
    /// Exchanges the tokens, consuming some of the `zksync::msg.token_address` and returning
    /// some of the `withdraw_token_address` to the client.
    ///
    pub fn swap(
        mut self,
        withdraw_address: Address,
        withdraw_token_address: TokenAddress,
        min_withdraw: Balance,
    ) {
        require(
            zksync::msg.recipient == self.address,
            "Transaction recipient is not the contract",
        );

        let deposit_idx = self.token_position(TokenAddress::from_address(zksync::msg.token_address));
        let withdraw_idx = self.token_position(withdraw_token_address);

        let balance_array = self.get_balance_array();

        require(balance_array[deposit_idx] != 0, "Deposit token balance is zero");
        require(balance_array[withdraw_idx] != 0, "Withdraw token balance is zero");

        let new_x = balance_array[deposit_idx] + zksync::msg.amount;
        let new_y = exchange::after(
            self.tokens,
            balance_array,
            self.amplifier,

            deposit_idx,
            withdraw_idx,
            new_x,
        );

        let old_y = balance_array[withdraw_idx];
        require(
            old_y >= min_withdraw + new_y,
            "Exchange resulted in fewer coins than expected",
        );
        let withdraw_amount = old_y - new_y;

        self.transfer(
            withdraw_address,
            withdraw_token_address,
            withdraw_amount,
        );
    }

    ///
    /// Given the amount to withdraw, returns the amount that must be deposited.
    ///
    pub fn get_dx(
        self,
        deposit_token_address: TokenAddress,
        withdraw_token_address: TokenAddress,
        to_withdraw: Balance,
    ) -> Balance {
        let deposit_idx = self.token_position(deposit_token_address);
        let withdraw_idx = self.token_position(withdraw_token_address);

        let balance_array = self.get_balance_array();

        require(balance_array[deposit_idx] != 0, "Deposit token balance is zero");
        require(balance_array[withdraw_idx] != 0, "Withdraw token balance is zero");

        let after_withdrawal = balance_array[withdraw_idx] - to_withdraw;
        
        let after_deposit = exchange::after(
            self.tokens,
            balance_array,
            self.amplifier,

            withdraw_idx,
            deposit_idx,
            after_withdrawal,
        );

        after_deposit - balance_array[deposit_idx]
    }

    ///
    /// Given the amount to deposit, returns the amount that will be withdrawn.
    ///
    pub fn get_dy(
        self,
        deposit_token_address: TokenAddress,
        withdraw_token_address: TokenAddress,
        to_deposit: Balance,
    ) -> Balance {
        let deposit_idx = self.token_position(deposit_token_address);
        let withdraw_idx = self.token_position(withdraw_token_address);

        let balance_array = self.get_balance_array();

        require(balance_array[deposit_idx] != 0, "Deposit token balance is zero");
        require(balance_array[withdraw_idx] != 0, "Withdraw token balance is zero");

        let after_deposit = balance_array[deposit_idx] + to_deposit;
        
        let after_withdrawal = exchange::after(
            self.tokens,
            balance_array,
            self.amplifier,

            deposit_idx,
            withdraw_idx,
            after_deposit,
        );

        balance_array[withdraw_idx] - after_withdrawal
    }

    /// 
    /// Given a token ID, returns the token position in the array of balances.
    /// 
    fn token_position(
        self,
        token_address: TokenAddress,
    ) -> u8 {
        let mut position = N;
        let mut found = false;

        for i in 0..N while !found {
            if self.tokens[i] == token_address {
                position = i;
                found = true;
            }
        }

        require(found, "The token is not being traded in this pool");

        position
    }

    /// 
    /// Creates an array of balances from the inner balance map.
    ///
    fn get_balance_array(self) -> [Balance; N] {
        let mut array = [0 as Balance; N];
        for i in 0..N {
            let (balance, found) = self.balances.get(self.tokens[i] as Address);
            if found {
                array[i] = balance;
            }
        }
        array
    }
}

The invariant module

use crate::constants::ZERO;
use crate::constants::N;

///
/// The `D` invariant calculation function.
///
/// The function is quite generic and does not work on token balances directly.
/// The only requirement for the `values` is to be of the same precision
/// to avoid incorrect amplification.
///
pub fn calculate(
    values: [u248; N],
    amplifier: u64,
) -> u248 {
    let mut sum = ZERO;
    for i in 0..N {
        sum += values[i];
    }

    if sum != ZERO {
        let mut D_prev = ZERO;
        let mut D = sum;

        let amplifier_N: u248 = amplifier * (N as u64);

        for _n in 0..15 while
            (D > D_prev && D - D_prev > 0) ||
            (D <= D_prev && D_prev - D > ZERO)
        {
            let mut D_P = D;

            for i in 0..N {
                // +1 is to prevent division by 0
                D_P = D_P * D / (values[i] * (N as u248) + 1);
            }

            D_prev = D;
            D = (amplifier_N * sum + D_P * (N as u248)) * D /
                ((amplifier_N - 1) * D + ((N + 1) as u248) * D_P);
        }

        D
    } else {
        ZERO
    }
}

The swap module

//!
//! The swap consequences calculation.
//!

use crate::types::Balance;
use crate::types::token_address::TokenAddress;
use crate::constants::ZERO;
use crate::constants::PRECISION_MUL;
use crate::constants::N;

///
/// The token being withdrawn balance after the swap.
///
pub fn after(
    tokens: [TokenAddress; N],
    balances: [Balance; N],
    amplifier: u64,

    token_x_idx: u8,
    token_y_idx: u8,
    after_x: Balance,
) -> Balance {
    require(token_x_idx != token_y_idx, "Cannot exchange between the same coins");

    let mut balances_p = balances;
    for i in 0..N {
        balances_p[i] *= tokens[i].magnitude_diff() * PRECISION_MUL;
    }

    let D = crate::invariant::calculate(balances_p, amplifier);
    let An: Balance = amplifier * (N as u64);

    let x_magnitude_diff = tokens[token_x_idx].magnitude_diff() * PRECISION_MUL;
    let y_magnitude_diff = tokens[token_y_idx].magnitude_diff() * PRECISION_MUL;

    let mut c = D;
    let mut S: Balance = ZERO;

    for i in 0..N {
        if i == token_x_idx as u8 {
            let after_x_p = after_x * x_magnitude_diff;
            S += after_x_p;
            c = c * D / (after_x_p * (N as Balance));
        } else if i != token_y_idx as u8 {
            S += balances_p[i];
            c = c * D / (balances_p[i] * (N as Balance));
        };
    }

    c = c * D / (An * (N as Balance));
    let b: Balance = S + D / An;

    let mut y = D;
    let mut y_next = y;
    let mut y_done = false;
    for n in 0..15 while !y_done {
        y_next = (y * y + c) / (2 * y + b - D);

        let is_next =
            (y > y_next && y - y_next > y_magnitude_diff) ||
            (y <= y_next && y_next - y > y_magnitude_diff);

        if is_next {
            y = y_next;
        } else {
            y_done = true;
        };
    }

    y / y_magnitude_diff
}

Troubleshooting

This chapter describes the most common issues with smart contract development. And some ways of fixing them, of course.

Unlocking a zkSync account

[ERROR zargo] action failed: HTTP error (422 Unprocessable Entity) Initial transfer: Account is locked

OR

[ERROR zargo] transaction: signing error: Signing key is not set in account

When you have created a new zkSync account and minted some trial tokens, you should see the following interface:

Click the Transfer button. In the next window, click the Unlock button and follow the instructions.

This sequence sets the public key for your zkSync account, so it gets ready for interaction with your smart contract accounts.

Resetting the input data

[ERROR zargo] transaction: signing error: Signing failed: Transfer is incorrect, check amounts

This error will become more informative and accurate in the future versions.

The most effective way of fixing it is making zargo clean and zargo build, since some elements of your data/input.json file got outdated.

Using a unique contract name

[ERROR zargo] project uploading request: HTTP error (503 Service Unavailable) Database:
Database(PgDatabaseError { severity: Error, code: "23505", message: "duplicate key value
violates unique constraint \"projects_pkey\"", detail: ... })

The contract name, version, and instance must be unique. To fix this issue, change your contract name or version in the Zargo.toml manifest, or use another instance name during publishing. To see all uploaded projects, use the zargo download --list command.

Zero-knowledge circuits

A Zinc circuit consists of the entry point file called main.zn and zero or more modules whose contents can be imported into the main file.

The entry point file must contain the main function, which accepts secret witness data and returns public input data. For more detail, see the next section.

Example

Entry point file

//! 
//! 'src/main.zn'
//!
//! Proves a knowledge of a cube root `r` for a given public input `x`.
//!

mod simple_math;

fn main(x: field) -> field {
    simple_math::cube(x)
}

Module simple_math file

//! 
//! 'src/simple_math.zn'
//! 

/// Returns x^3.
fn cube(x: field) -> field {
    x * x * x
}

Input and output

In terms of zero-knowledge circuits, the information that we are trying to prove valid is called public input. The secret piece of information that may be known only by the prover is called witness.

In the Zinc framework, the circuit output becomes public input. This means that whatever the main function returns should be known by the verifier. All other runtime values including arguments represent the circuit witness data.

So when verifier checks the circuit output and the proof it is safe to state that:

There is some set of arguments known to prover, which, being provided to circuit yields the same output.

The prover must provide arguments to the application to generate the result and proof.

Verifier will use the proof to check that the result has been obtained by executing the circuit.

The following example illustrates a circuit proving knowledge of some sha256 hash preimage:

use std::crypto::sha256;

fn main(preimage: [bool; 512]) -> [bool; 256] {
    sha256(preimage)
}

Minimal example

Proof verification is temporarily unsupported in Zinc 0.2. Only building and running circuits is possible now.

Creating a circuit

Let's create our first circuit, which will be able to prove knowledge of some sha256 hash preimage:

zargo new --type circuit preimage
cd preimage

The command above will create a directory with Zargo.toml manifest and the src/ folder with an entry point module main.zn.

Let's replace the main.zn contents with the following code:

use std::crypto::sha256;
use std::convert::to_bits;
use std::array::pad;

const FIELD_SIZE: u64 = 254;
const FIELD_SIZE_PADDED: u64 = FIELD_SIZE + 2 as u64;
const SHA256_HASH_SIZE: u64 = 256;

fn main(preimage: field) -> [bool; SHA256_HASH_SIZE] {
    let preimage_bits: [bool; FIELD_SIZE] = to_bits(preimage);
    let preimage_bits_padded: [bool; FIELD_SIZE_PADDED] = pad(preimage_bits, 256, false);
    sha256(preimage_bits_padded)
}

All-in-one command

When you have finished writing the code, run zargo proof-check. This command will build and run the circuit, generate keys for the trusted setup, generate a proof and verify it.

Step by step

Let's get through each step of the command above manually to better understand what is under the hood. Before you start, run zargo clean to remove all the build artifacts.

Building the circuit

Now, you need to compile the circuit into Zinc bytecode:

zargo build

The command above will write the bytecode to the build directory located in the project root. There is also a file called input.json in the data directory, which is used to provide the secret witness data to the circuit.

Running the circuit

Before you run the circuit, open the ./data/input.json file with your favorite editor and fill it with some meaningful values.

Now, execute zargo run > ./data/output.json to run the circuit and write the resulting public data to a file.

There is a useful tool called jq. You may use it together with zargo run to highlight, edit, filter the output data before writing it to the file: zargo run | jq > ./data/output.json.

For more information on jq, visit the official manual.

Trusted setup

To be able to verify proofs, you must create a pair of keys for the prover and the verifier.

To generate a new pair of proving and verifying keys, use this command:

zargo setup

Generating a proof

To generate a proof, provide the witness and public data to the Zinc VM with the following command:

zargo prove > proof.txt

Verifying a proof

Before verifying a proof, make sure the prover and verifier use the same version of the Zinc framework.

To verify a proof, pass it to the Zinc VM with the same public data you used to generate it and the verification key:

zargo verify < proof.txt

Congratulations! You have developed your first circuit and verified your first Zero-Knowledge Proof!

Now you may proceed to implementing the more complex example.

The Merkle tree

In this chapter, we will implement a circuit able to validate the Merkle tree root hash.

At this stage of reading the book, you may be unfamiliar with some language concepts. So, if you struggle to understand some examples, you are welcome to read the rest of the book first, and then come back.

Our circuit will accept the tree node path, address, and the balance available as the secret witness data. The public data will be the Merkle tree root hash.

Creating a new project

Let's create a new circuit called merkle-proof:

zargo new --type circuit merkle-proof
cd merkle-proof

Now, you can open the project in your favorite IDE and go to src/main.zn, where we are going to start writing the circuit code.

Defining types

Let's start by defining the secret witness data arguments and the public data return type.

struct PublicInput {
    root_hash: [bool; 256],
}

fn main(
    address: [bool; 10], // the node address in the merkle tree
    balance: field, // the balance stored in the node
    merkle_path: [[bool; 256]; 10] // the hash path to the node
) -> PublicInput {
    // ...
}

As you can see, some complex types are used in several places of our code, so it is very convenient to create an alias for such type.

type Sha256Digest = [bool; 256];

Creating functions

Now, we will write a function to calculate the sha256 hash of our balance. We need it to verify the balance stored within the leaf node at our Merkle tree path.

fn balance_hash(balance: field) -> Sha256Digest {
    let bits = std::convert::to_bits(balance); // [bool; 254]
    let bits_padded = std::array::pad(bits, 256, false); // [bool; 256]
    std::crypto::sha256(bits_padded) // [bool; 256] a.k.a. Sha256Digest
}

The function accepts balance we passed as secret witness data, converts it into a bit array of length 254 (elliptic curve field length), and pads the array with 2 extra zero bits, since we are going to pass a 256-bit array to the sha256 function.

We have also used here three functions from the standard library from three different modules. Paths like std::crypto::sha256 might seem a bit verbose, but we will solve this problem later.

At this stage, this is how our code looks like:

type Sha256Digest = [bool; 256];

struct PublicInput {
    root_hash: Sha256Digest,
}

fn balance_hash(balance: field) -> Sha256Digest {
    let bits = std::convert::to_bits(balance); // [bool; 254]
    let bits_padded = std::array::pad(bits, 256, false); // [bool; 256]
    std::crypto::sha256(bits_padded) // [bool; 256] a.k.a. Sha256Digest
}

fn main(
    address: [bool; 10], // the node address in the merkle tree
    balance: field, // the balance stored in the node
    merkle_path: [Sha256Digest; 10] // the hash path to the node
) -> PublicInput {
    let leaf_hash = balance_hash(balance);

    // ...
}

Now, we need a function to calculate a tree node hash:

fn merkle_node_hash(left: Sha256Digest, right: Sha256Digest) -> Sha256Digest {
    let mut data = [false; 512]; // [bool; 512]

    // Casting to u16 is needed to make the range types equal,
    // since 0 will be inferred as u8, and 256 - as u16.
    for i in 0 as u16..256 {
        data[i] = left[i];
        data[256 + i] = right[i];
    }

    std::crypto::sha256(data) // [bool; 256] a.k.a. Sha256Digest
}

The Zinc standard library does not support array concatenation yet, so for now, we will do it by hand, allocating an array to contain two leaves node digests, then put the digests together and hash them with std::crypto::sha256.

Finally, let's define a function to calculate the hash of the whole tree:

fn restore_root_hash(
    leaf_hash: Sha256Digest,
    address: [bool; 10],
    merkle_path: [Sha256Digest; 10],
) -> Sha256Digest
{
    let mut current = leaf_hash; // Sha256Digest

    // Traverse the tree from the left node to the root node
    for i in 0..10 {
        // Multiple variables binding is not supported yet,
        // so we going to store leaves as an array of two digests.
        // If address[i] is 0, we are in the left node, otherwise,
        // we are in the right node.
        let left_and_right = if address[i] {
            [current, merkle_path[i]] // [Sha256Digest; 2]
        } else {
            [merkle_path[i], current] // [Sha256Digest; 2]
        };

        // remember the current node hash
        current = merkle_node_hash(left_and_right[0], left_and_right[1]);
    }

    // return the root node hash
    current
}

Congratulations! Now we have a working circuit able to verify the Merkle proof!

// main.zn

type Sha256Digest = [bool; 256];

fn balance_hash(balance: field) -> Sha256Digest {
    let bits = std::convert::to_bits(balance); // [bool; 254]
    let bits_padded = std::array::pad(bits, 256, false); // [bool; 256]
    std::crypto::sha256(bits_padded) // [bool; 256] a.k.a. Sha256Digest
}

fn merkle_node_hash(left: Sha256Digest, right: Sha256Digest) -> Sha256Digest {
    let mut data = [false; 512]; // [bool; 512]

    for i in 0..256 {
        data[i] = left[i];
        data[256 + i] = right[i];
    }

    std::crypto::sha256(data) // [bool; 256] a.k.a. Sha256Digest
}

fn restore_root_hash(
    leaf_hash: Sha256Digest,
    address: [bool; 10],
    merkle_path: [Sha256Digest; 10],
) -> Sha256Digest
{
    let mut current = leaf_hash; // Sha256Digest

    // Traverse the tree from the left node to the root node
    for i in 0..10 {
        // Multiple variables binding is not supported yet,
        // so we going to store leaves as a tuple of two digests.
        // If address[i] is 0, we are in the left node, otherwise,
        // we are in the right node.
        let left_and_right = if address[i] {
            (current, merkle_path[i]) // (Sha256Digest, Sha256Digest)
        } else {
            (merkle_path[i], current) // (Sha256Digest, Sha256Digest)
        };

        // remember the current node hash
        current = merkle_node_hash(left_and_right.0, left_and_right.1);
    }

    // return the root node hash
    current
}

struct PublicInput {
    root_hash: Sha256Digest,
}

fn main(
    address: [bool; 10],
    balance: field,
    merkle_path: [Sha256Digest; 10]
) -> PublicInput {
    let leaf_hash = balance_hash(balance);

    let root_hash = restore_root_hash(
        leaf_hash,
        address,
        merkle_path,
    );

    PublicInput {
        root_hash: root_hash,
    }
}

Defining a module

Our main.zn module has got a little overpopulated by now, so let's move our functions to another one called merkle. At first, create a file called merkle.zn in the src directory besides main.zn. Then, move everything above the PublicInput definition to that file. Our main.zn will now look like this:

struct PublicInput {
    root_hash: Sha256Digest, // undeclared `Sha256Digest`
}

fn main(
    address: [bool; 10],
    balance: field,
    merkle_path: [Sha256Digest; 10] // undeclared `Sha256Digest`
) -> PublicInput {
    let leaf_hash = balance_hash(balance); // undeclared `balance_hash`

    let root_hash = restore_root_hash( // undeclared `restore_root_hash`
        leaf_hash,
        address,
        merkle_path,
    );

    PublicInput {
        root_hash: root_hash,
    }
}

This code will not compile, as we have several items undeclared now! Let's define our merkle module and resolve the function paths:

mod merkle; // defined a module

struct PublicInput {
    root_hash: merkle::Sha256Digest, // use a type declaration from `merkle`
}

fn main(
    address: [bool; 10],
    balance: field,
    merkle_path: [merkle::Sha256Digest; 10] // use a type declaration from `merkle`
) -> PublicInput {
    let leaf_hash = merkle::balance_hash(balance); // call a function from `merkle`

    // call a function from `merkle`
    let root_hash = merkle::restore_root_hash(
        leaf_hash,
        address,
        merkle_path,
    );

    PublicInput {
        root_hash: root_hash,
    }
}

Perfect! Now all our functions and types are defined. By the way, let's have a glance at our merkle module, where you can find another improvement!

use std::crypto::sha256; // an import

type Sha256Digest = [bool; 256];

fn balance_hash(balance: field) -> Sha256Digest {
    let bits = std::convert::to_bits(balance);
    let bits_padded = std::array::pad(bits, 256, false);
    sha256(bits_padded)
}

fn merkle_node_hash(left: Sha256Digest, right: Sha256Digest) -> Sha256Digest {
    let mut data = [false; 512];

    for i in 0..256 {
        data[i] = left[i];
        data[256 + i] = right[i];
    }

    sha256(data)
}

fn restore_root_hash(
    leaf_hash: Sha256Digest,
    address: [bool; 10],
    merkle_path: [Sha256Digest; 10],
) -> Sha256Digest
{
    let mut current = leaf_hash;

    for i in 0..10 {
        let left_and_right = if address[i] {
            (current, merkle_path[i])
        } else {
            (merkle_path[i], current)
        };

        current = merkle_node_hash(left_and_right.0, left_and_right.1);
    }

    current
}

You may notice a use statement at the first line of code. It is an import statement which is designed to prevent using long repeated paths in our code. As you see, now we are able to call the standard library function more conveniently: sha256(data) instead of std::crypto::sha256(data).

Finalizing

Congratulations, you are an experienced Zinc developer! Now, you may build the circuit, generate and verify a proof, like it was explained in the previous chapter, and move on to reading the rest of the book!

Virtual machine

The current VM is not supported anymore, as we move to another concept. Follow our official channels in order to get the latest information.

Zinc code is compiled into bytecode which can be run by Zinc VM.

Zinc VM is a virtual machine that serves three purposes: executing arbitrary computations, generating zero-knowledge proof of performed computations, and verification of the provided proof without knowing the input data.

Zinc VM is a stack-based virtual machine which is similar to many others like the Python VM. Even though the VM is designed considering specifics and limitations of zero-knowledge computations, bytecode instructions only manipulate data on the stack while all zero-knowledge constraints are automatically applied by the virtual machine.

Zargo package manager

Zargo is a project managing tool, which can create and build projects, generate and verify proofs, publish smart contracts and call their methods.

General commands

All the commands have default values, so you may omit them in normal circumstances. See zargo --help for more detail.

new

Creates a new project directory with Zargo.toml manifest file and src/main.zn application entry point module.

init

Initializes a new project in an existing directory, creates missing files.

build

Builds the project. The build consists of:

  • the bytecode file
  • input JSON template
  • output JSON template

clean

Removes the build directory.

run

Build and runs the application on the Zinc VM, writes the result to the terminal.

test

Runs the application unit tests.

setup

Generates parameters for the prover using the application bytecode.

prove

Generates the proof using the application bytecode, parameters generated with setup, and provided public data.

verify

Verifies the proof using the application bytecode, parameters generated with setup, proof generated with prove, and provided public data.

proof-check

Executes the full cycle of proof verification, that is, performs run + setup + prove + verify. Mostly for testing purposes.

Smart contract commands

publish

Publishes the smart contract to the Zandbox server on the specified network.

query

Queries a smart contract storage or calls an immutable method.

call

Calls a mutable smart contract method, that is, one modifying its storage and making operations with tokens and balances.

upload

Uploads the project to the Zandbox server on the specified network.

download

Downloads the project from the Zandbox server on the specified network.

Contract workflow

This code snippet describes the workflow of creating, building, publishing a smart contract and calling its methods.

# create a new contract called 'swap'
zargo new --type contract swap
cd swap/

# write some code

# rebuild, publish the contract, and get its address
zargo publish --instance default --network rinkeby

# query the newly created contract storage
zargo query --address <address>

# call some contract method
zargo call --method exchange --address <address>

Manifest file

A Zinc smart contract is described in the manifest file Zargo.toml with the following structure:

[project]
name = 'test'
type = 'contract'
version = '0.1.0'

Circuit workflow

Short

The short example includes the proof-check command, which executes a full application lifecycle with default data.

# create a new circuit called 'zircuit'
zargo new --type circuit zircuit
cd zircuit/

# write some code

# run the full verification cycle
zargo proof-check

Full

The full workflow example allows you to go through the application lifecycle step by step and see all its intrincics.

# create a new circuit called 'zircuit'
zargo new --type circuit zircuit
cd zircuit/

# write some code

# build the circuit
zargo build

# run the circuit and print the result
zargo run

# generate the prover parameters
zargo setup

# edit the './data/input.json' and './data/output.json' files

# generate the proof
zargo prove

# verify the proof
zargo verify

Manifest file

A Zinc circuit is described in the manifest file Zargo.toml with the following structure:

[project]
name = "test"
type = "circuit"
version = "0.1.0"

Dependency system

Zargo provides the possibility to use other Zinc projects as dependencies.

To use a dependency, specify its name and version in the dependencies section of your Zargo.toml project manifest:

[project]
name = 'caller'
type = 'contract'
version = '0.1.0'

[dependencies]
callee = '0.1.0'

Then, the dependency project will be available through the main one as an ordinar module. So easy!

use callee::Callee;

contract Caller {
    pub value: u64;

    pub fn new(value: u64) -> Self {
        Self {
            value: value,
        }
    }

    pub fn create_and_transfer(mut self) {
        // creates an instance of contract `Callee`
        let mut instance = Callee::new(self.value / 2);
        
        // sends some tokens to the newly created instance
        self.transfer(instance.address, 0x0 as u160, 0.1_E18 as u248);
        
        // sends half of the tokens back to the creator
        instance.transfer(self.address, 0x0 as u160, 0.05_E18 as u248);
    }
}

Library project type

The library project is simply a collection of types and functions, which cannot be run as a separate project. Instead, it can be uploaded to Zandbox and used as a dependency. To create a library, initialize a project with the library type:

zargo new --type library math

Uploading a project

To upload your project to the Zandbox database, simply use the zargo upload command. The project name and version must be unique. To check which ones are already occupied, use this command:

zargo download --list

Downloading a project

Usually, all the project dependencies are downloaded by default and stored in the target/deps directory relative to the main project root.

However, sometimes you need to download some project of yours or somebody else's to make useful changes and tweaks. To do that, use the following command:

zargo download --name callee --version 0.1.0

Appendix

The following sections contain reference material you may find useful in your Zinc journey.

Lexical grammar (EBNF)

lexeme = comment | identifier | keyword | literal | symbol | EOF ;

comment = single_line_comment | multi_line_comment ;
single_line_comment = '//', ( ? ANY ? - '\n' | EOF ), '\n' | EOF ;
multi_line_comment = '/*', ( ? ANY ? - '*/' ), '*/' ;

identifier = (
    alpha, { alpha | digit | '_' }
  | '_', alpha, { alpha }
- keyword ) ;

keyword =
    'let'
  | 'mut'
  | 'const'
  | 'type'
  | 'struct'
  | 'enum'
  | 'fn'
  | 'mod'
  | 'use'
  | 'impl'
  | 'contract'
  | 'pub'

  | 'for'
  | 'in'
  | 'while'
  | 'if'
  | 'else'
  | 'match'

  | 'bool'
  | 'u8' | 'u16' | 'u24' | 'u32' | 'u40' | 'u48' | 'u56' | 'u64'
  | 'u72' | 'u80' | 'u88' | 'u96' | 'u104' | 'u112' | 'u120' | 'u128'
  | 'u136' | 'u144' | 'u152' | 'u160' | 'u168' | 'u176' | 'u184' | 'u192'
  | 'u200' | 'u208' | 'u216' | 'u224' | 'u232' | 'u240' | 'u248'
  | 'i8' | 'i16' | 'i24' | 'i32' | 'i40' | 'i48' | 'i56' | 'i64'
  | 'i72' | 'i80' | 'i88' | 'i96' | 'i104' | 'i112' | 'i120' | 'i128'
  | 'i136' | 'i144' | 'i152' | 'i160' | 'i168' | 'i176' | 'i184' | 'i192'
  | 'i200' | 'i208' | 'i216' | 'i224' | 'i232' | 'i240' | 'i248'
  | 'field'

  | 'true'
  | 'false'

  | 'as'

  | 'crate'
  | 'super'
  | 'self'
  | 'Self'

  | 'static'
  | 'ref'
  | 'extern'
  | 'return'
  | 'loop'
  | 'break'
  | 'continue'
  | 'trait'
;

literal = boolean | integer | string ;
boolean = 'true' | 'false' ;
integer =
    '0'
  | '0b', binary_digit | '_', { binary_digit | '_' }
  | '0o', octal_digit | '_', { octal_digit | '_' }
  | decimal_digit - '0', { decimal_digit | '_' }
  | '0x', hexadecimal_digit | '_', { hexadecimal_digit | '_' }
;
string = '"', { ANY - '"' | '\', ANY }, '"' ;

symbol =
    '('
  | ')'
  | '['
  | ']'
  | '{'
  | '}'
  | '_'
  | '.'
  | ':'
  | ';'
  | ','
  | '='
  | '+'
  | '-'
  | '*'
  | '/'
  | '%'
  | '\'
  | '!'
  | '<'
  | '>'
  | '|'
  | '&'
  | '^'
  | '~'
  | '#'
  | '<<'
  | '>>'
  | '+='
  | '-='
  | '*='
  | '/='
  | '%='
  | '|='
  | '&='
  | '^='
  | '::'
  | '=='
  | '!='
  | '<='
  | '>='  
  | '&&'
  | '^^'
  | '||'
  | '..'
  | '..='
  | '<<='
  | '>>='
  | '=>'
  | '->'
;

alpha =
    'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G'
  | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N'
  | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U'
  | 'V' | 'W' | 'X' | 'Y' | 'Z' 
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g'
  | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n'
  | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u'
  | 'v' | 'w' | 'x' | 'y' | 'z'
;

binary_digit = '0' | '1' ;

octal_digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' ;

decimal_digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ;

hexadecimal_digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
  | 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
;

The Zinc alphabet

GroupCharacters
whitespaces\t \n \r
lowercaseA B C D E F G H I J K L M N O P Q R S T U V W X Y Z
uppercasea b c d e f g h i j k l m n o p q r s t u v w x y z
numbers0 1 2 3 4 5 6 7 8 9
symbols+ - * / % < = > ⎮ & ^ _ ! ~ ( ) [ ] { } " , . : ; #

Syntax grammar (EBNF)

file = { module_local_statement } ;

(* Statements *)
module_local_statement =
    const_statement
  | type_statement
  | struct_statement
  | enum_statement
  | fn_statement
  | mod_statement
  | use_statement
  | impl_statement
  | contract_statement
  | empty_statement
;

function_local_statement =
    let_statement
  | const_statement
  | loop_statement
  | empty_statement
  | expression, [ ';' ]
;

implementation_local_statement =
    const_statement
  | fn_statement
  | empty_statement
;

contract_local_statement =
    field_statement
  | const_statement
  | fn_statement
  | empty_statement
;

field_statement = [ 'pub' ], [ 'extern' ], identifier, ':', type, ';' ;

type_statement = [ 'pub' ], 'type', identifier, '=', type, ';' ;

struct_statement = [ 'pub' ], 'struct', '{', field_list, '}' ;

enum_statement = [ 'pub' ], 'enum', '{', variant_list, '}' ;

fn_statement = [ 'pub' ], [ 'const' ], 'fn', identifier, '(', binding_list, ')', [ '->', type ], block_expression ;

mod_statement = [ 'pub' ], 'mod', identifier, ';' ;

use_statement = [ 'pub' ], 'use', path_expression, [ 'as', identifier ], ';' ;

impl_statement = 'impl', identifier, '{', { implementation_local_statement }, '}' ;

const_statement = [ 'pub' ], 'const', identifier, ':', type, '=', expression, ';' ;

let_statement = 'let', binding, '=', expression, ';' ;

loop_statement = 'for', identifier, 'in', expression, [ 'while', expression ], block_expression ;

contract_statement = 'contract', '{', { contract_local_statement }, '}' ;

empty_statement = ';' ;

(* Expressions *)
expression = operand_assignment, [ '=' | '+=' | '-=' | '*=' | '/=' | '%=' | '<<=' | '>>=' | '|=' | '^=' | '&=', operand_assignment ] ;
operand_assignment = operand_range, [ '..' | '..=', operand_range ] ;
operand_range = operand_or, { '||', operand_or } ;
operand_or = operand_xor, { '^^', operand_xor } ;
operand_xor = operand_and, { '&&', operand_and } ;
operand_and = operand_comparison, [ '==' | '!=' | '>=' | '<=' | '>' | '<', operand_comparison ] ;
operand_comparison = operand_bitwise_or, { '|', operand_bitwise_or } ;
operand_bitwise_or = operand_bitwise_xor, { '^', operand_bitwise_xor } ;
operand_bitwise_xor = operand_bitwise_and, { '&', operand_bitwise_and } ;
operand_bitwise_and = operand_bitwise_shift, { '<<' | '>>', operand_bitwise_shift } ;
operand_bitwise_shift = operand_add_sub, { '+' | '-', operand_add_sub } ;
operand_add_sub = operand_mul_div_rem, { '*' | '/' | '%', operand_mul_div_rem } ;
operand_mul_div_rem = operand_as, { 'as', type } ;
operand_as = { '-' | '~' | '!' }, operand_access ;
operand_access = operand_path, {
    '[', expression, ']'
  | '.', integer | identifier
  | [ '!' ], '(', expression_list, ')'
} ;
operand_path = operand_terminal, { '::', operand_terminal }, [ structure_expression ] ;
operand_terminal =
    tuple_expression
  | block_expression
  | array_expression
  | conditional_expression
  | match_expression
  | literal
  | identifier
  | alias
;

expression_list = [ expression, { ',', expression } | ',' ] ;

block_expression = '{', { function_local_statement }, [ expression ], '}' ;

conditional_expression = 'if', expression, block_expression, [ 'else', conditional_expression | block_expression ] ;

match_expression = 'match', expression, '{', { pattern_match, '=>', expression, ',' }, '}' ;

array_expression =
    '[', [ expression, { ',', expression } ] ']'
  | '[', expression, ';', integer, ']'
;

tuple_expression =
    '(', ')'
  | '(', expression, ')'
  | '(', expression, ',', [ expression, { ',', expression } ], ')'
;

structure_expression = '{', field_list, '}';

(* Attributes *)
attribute = '#', [ '!' ], '[', attribute_element_list, ']' ;
attribute_element = 
    identifier
  | identifier, '=', literal
  | identifier, '(', attribute_element_list, ')' ;
attribute_element_list = [ attribute_element, { ',', attribute_element } | ',' ] ;

(* Parts *)
alias = 'crate' | 'super' | 'self' | 'Self'

type =
    '(', ')'
  | 'bool'
  | 'u8' | 'u16' | 'u24' | 'u32' | 'u40' | 'u48' | 'u56' | 'u64'
  | 'u72' | 'u80' | 'u88' | 'u96' | 'u104' | 'u112' | 'u120' | 'u128'
  | 'u136' | 'u144' | 'u152' | 'u160' | 'u168' | 'u176' | 'u184' | 'u192'
  | 'u200' | 'u208' | 'u216' | 'u224' | 'u232' | 'u240' | 'u248' | 'field'
  | 'i8' | 'i16' | 'i24' | 'i32' | 'i40' | 'i48' | 'i56' | 'i64'
  | 'i72' | 'i80' | 'i88' | 'i96' | 'i104' | 'i112' | 'i120' | 'i128'
  | 'i136' | 'i144' | 'i152' | 'i160' | 'i168' | 'i176' | 'i184' | 'i192'
  | 'i200' | 'i208' | 'i216' | 'i224' | 'i232' | 'i240' | 'i248'
  | 'field'
  | '[', type, ';', expression, ']'
  | '(', type, { ',', type }, ')'
  | identifier | alias, { '::', identifier | alias }
;

pattern_match =
    boolean
  | integer
  | identifier
  | operand_path
  | '_'
;

binding = pattern_binding, [ ':', type ] ;
binding_list = [ binding, { ',', binding } | ',' ] ;
pattern_binding =
    [ 'mut' ], identifier
  | ( pattern_binding, { ',', pattern_binding } | ',' ) 
  | '(', ')'
  | '_'
;

field = identifier, ':', type ;
field_list = [ field, { ',', field } | ',' ] ;

variant = identifier, '=', integer ;
variant_list = [ variant, { ',', variant } | ',' ] ;

Keywords

Declarations

let
mut
const
type
struct
enum
fn
use
mod
impl
contract
pub

Controls

for
in
while
if
else
match

Types

bool
u8 u16 ... u240 u248
i8 i16 ... i240 i248
field

Literals

true
false

Operators

as

Aliases

crate
super
self
Self

Reserved

static
extern
ref
return
loop
break
continue
trait

Intrinsic functions

Intrinsic functions are special and usually correspond to dedicated Zinc VM instructions.

dbg

Prints its arguments to the terminal. Only for debugging purposes.

Arguments:

  • format string literal (str)
  • rest of the arguments to print

Return type: ()

Note: This function is special, as it accepts an arbitrary number of arguments of any type after the format string.

require

Checks if the boolean expression is true. If it is not, the circuit fails with an error passed as the second argument.

Arguments:

  • boolean expression (bool)
  • error message string literal (str)

Return type: ()

This is the only function able to halt the application execution.

<Contract>::transfer function

Executes a transfer which is eventually sent to the zkSync platform.

Is automatically defined as a method in every smart contract.

Arguments:

  • sender: <Contract>
  • recipient: u160
  • token_address: u160
  • amount: u248

Returns: ()

<Contract>::fetch function

Loads a contract instance from the Zandbox server.

Is automatically defined as a static function in every smart contract.

Arguments:

  • address: u160

Returns: <Contract>

The standard library

The standard library is unstable. Function signatures and behavior are going to be changed in future releases.

Most of the functions described here are special, as they accept arrays of arbitrary size. Since there are only fixed-size arrays in Zinc now, it would be challenging to create a function for arrays of every possible size. It is not possible to write such a function yourself using the language type system, but std makes an exception to simplify development for now.

Definitions

  • {scalar} - a scalar type, which can be bool, u{N}, i{N}, field
  • u{N} - an unsigned integer of bitlength N
  • i{N} - a signed integer of bitlength N
  • field - a field element of bitlength 254

std::crypto module

std::crypto::sha256

Computes the sha256 hash of a given bit array.

Will cause a compile-error if either:

  • preimage length is zero
  • preimage length is not multiple of 8

Arguments:

  • preimage bit array [bool; N]

Returns: 256-bit hash [bool; 256]

std::crypto::pedersen

Maps a bit array to a point on an elliptic curve.

Will cause a compile-error if either:

  • preimage length is zero
  • preimage length is greater than 512 bits

To understand what is under the hood, see this article.

Arguments:

  • preimage bit array [bool; N]

Returns: elliptic curve point coordinates (field, field)

std::crypto::ecc::Point

The elliptic curve point.

struct Point {
    x: field,
    y: field,
}

std::crypto::schnorr::Signature

The Schnorr EDDSA signature structure.

struct Signature {
    r: std::crypto::ecc::Point,
    s: field,
    pk: std::crypto::ecc::Point,
}

std::crypto::schnorr::Signature::verify

Verifies the EDDSA signature.

Will cause a compile-error if either:

  • message length is zero
  • message length is greater than 248 bits

Arguments:

  • the signature: std::crypto::schnorr::Signature
  • the message: [bool; N]

Returns: the boolean result

std::convert module

std::convert::to_bits

Converts a scalar value to a bit array of its bitlength.

Arguments:

  • scalar value: u{N}, or i{N}, or field

Returns: [bool; N]

std::convert::from_bits_unsigned

Converts a bit array to an unsigned integer of the array's bitlength.

Will cause a compile-error if either:

  • bit array size is zero
  • bit array size is greater than 248 bits
  • bit array size is not multiple of 8

Arguments:

  • bit array: [bool; N]

Returns: u{N}

std::convert::from_bits_signed

Converts a bit array to a signed integer of the array's bitlength.

Will cause a compile-error if either:

  • bit array size is zero
  • bit array size is greater than 248 bits
  • bit array size is not multiple of 8

Arguments:

  • bit array: [bool; N]

Returns: i{N}

std::convert::from_bits_unsigned

Converts a bit array to a field element.

Arguments:

  • bit array: [bool; 254]

Returns: field

std::array module

std::array::reverse

Reverses a given array.

Arguments:

  • array: [{scalar}; N]

Returns: [{scalar}; N]

std::array::truncate

Truncates an array of size N to an array of size new_length.

Will cause a compile-error if either:

  • array size is less than new length
  • new length is not a constant expression

Arguments:

  • array: [{scalar}; N]
  • new_length: u{N} or field

Returns: [{scalar}; new_length]

std::array::pad

Pads a given array with the given values.

Will cause a compile-error if either:

  • array size is greater than new length
  • new length is not a constant expression

Arguments:

  • array: [{scalar}; N]
  • new_length: u{N} or field
  • fill_value: {scalar}

Returns: [{scalar}; new_length]

std::ff module

std::ff::invert

Inverts a finite field.

Arguments:

  • value: field

Returns: field

std::collections module

std::collections::MTreeMap<K, V>

The map type, which can only be a contract storage field and accessed via the methods below.

std::collections::MTreeMap::get

Gets the value from the map. Returns the value and presence flag. If the presence flag is false, the value is filled with zeros.

Arguments:

  • key: K

Returns: (V, bool)

std::collections::MTreeMap::contains

Checks if the value exists in the map. Returns the presence flag.

Arguments:

  • key: K

Returns: bool

std::collections::MTreeMap::insert

Inserts the value into the map. Returns the old value and presence flag. If the presence flag is false, the old value is filled with zeros.

Arguments:

  • key: K
  • value: V

Returns: (V, bool)

std::collections::MTreeMap::remove

Removes the value from the map. Returns the removed value and presence flag. If the presence flag is false, the removed value is filled with zeros.

Arguments:

  • key: K

Returns: (V, bool)

The zkSync library

The zkSync library contains functions and utilities to perform operations in the zkSync networks.

zksync::msg variable

The built-in global transaction variable.

Fields:

  • sender: u160
  • recipient: u160
  • token_address: u160
  • amount: u248