Cairo is a statically typed, functional programming language with a focus on security and correctness. It is used in production by several major exchanges and trading firms. The language has been designed with a strong emphasis on safety and security, with the goal of eliminating entire classes of vulnerabilities. For example, Cairo does not allow null pointers, and all memory is automatically initialized to a safe value. The language has a small set of core features and a standard library that is heavily inspired by the Haskell ecosystem. The language is also very easy to learn, with a simple and concise syntax. However, like all languages, Cairo has its share of foot guns and weird features. In this article, we will discuss some of these and how to avoid them. One common issue that developers run into is the fact that Cairo's type system is very strict. This can lead to errors when trying to use functions that have different types. For example, the + operator can only be used on numbers and not on strings. Another issue is that Cairo does not have a concept of exceptions. This means that if a function encounters an error, it will simply return an error value. This can be confusing for developers who are used to exceptions being thrown. Finally, Cairo does not have a garbage collector. This means that developers have to manually manage memory. This can be error-prone and can lead to memory leaks. Amarna is a static analyzer and linter for Cairo. It is open source and available on GitHub. Amarna works by analyzing the source code of a program and looking for potential issues. It can find errors such as type errors, memory leaks, and undefined behavior. Amarna is still in its early stages and currently only supports a small subset of the Cairo language. However, we plan to add support for more features in the future. If you are interested in using Amarna or contributing to the project, please check out the GitHub repository.
Setting up and running Cairo code Now that we’ve briefly outlined the Cairo language in general, let’s discuss how to set up and run Cairo code. Consider the following simple Cairo program. This function computes the Pedersen hash function of a pair of numbers, (input, 1), and outputs the result in the console:
def pedersen(input, one): two = input * 2 three = one * 3 six = three * two return six
In order to run this program, we need to do the following:
Create a file called pedersen.cairo with the code above. Install the Cairo command-line tool. Run the command cairo-sharp pedersen.cairo. This will generate a file called pedersen.cairo.json, which contains the STARK proof for the program.
The file pedersen.cairo.json can be shared with anyone, and they can verify the computation by running the command cairo-verifier pedersen.cairo.json.
If we want to compute the Pedersen hash function for the input 5, we can do so by running the command cairo-eval pedersen.cairo 5. This will output the value 30 in the console.
Conclusion In this post, we provided an introduction to the Cairo programming language. We discussed the basics of the language and its ecosystem, and showed how to write and run a simple Cairo program.
Why do we need Cairo? The purpose of Cairo, and similar languages such as Noir and Leo, is to write “provable programs,” where one party runs the program and creates a proof that it returns a certain output when given a certain input.
Suppose we want to outsource a program’s computation to some (potentially dishonest) server and need to guarantee that the result is correct. With Cairo, we can obtain a proof that the program output the correct result; we need only to verify the proof rather than recomputing the function ourselves (which would defeat the purpose of outsourcing the computation in the first place).
In summary, we take the following steps:
Write the function we want to compute. Run the function on the worker machine with the concrete inputs, obtain the result, and generate a proof of validity for the computation. Validate the computation by validating the proof. The Cairo programming language As we just explained, the Cairo programming model involves two key roles: the prover, who runs the program and creates a proof that the program returns a certain output, and the verifier, who verifies the proofs created by the prover.
However, in practice, Cairo programmers will not actually generate or verify the proofs themselves. Instead, the ecosystem includes these three pillars:
The Shared Prover (SHARP) is a public prover that generates proofs of validity for program traces sent by users. The proof verifier contract verifies proofs of validity for program executions. The fact registry contract can be queried to check whether a certain fact is valid. The fact registry is a database that stores program facts, or values computed from hashes of programs and of their outputs; creating a program fact is a way to bind a program to its output.
This is the basic workflow in Cairo:
A user writes a program and submits its trace to the SHARP (via the Cairo playground or the command cairo-sharp). The SHARP creates a STARK proof for the program trace and submits it to the proof verifier contract. The proof verifier contract validates the proof, and, if valid, writes the program fact to the fact registry. Any other user can now query the fact registry contract to check whether that program fact is valid. There are two other things to keep in mind:
Memory in Cairo is write-once: after a value is written to memory, it cannot be changed. The assert statement assert a = b will behave differently depending on whether a is initialized: if a is uninitialized, the assert statement assigns b to a; if a is initialized, the assert statement asserts that a and b are equal. Although the details of Cairo’s syntax and keywords are interesting, we will not cover these topics in this post. The official Cairo documentation and Perama’s notes on Cairo are a good starting point for more information.
Cairo features and footguns Cairo has several quirks and footguns that can trip up new Cairo programmers. We will describe three Cairo features that are easily misused, leading to security issues: Cairo hints, the interplay between recursion and underconstrained structures, and non-deterministic jumps.
Hints Hints are special Cairo statements that basically enable the prover to write arbitrary Python code. Yes, the Python code written in a Cairo hint is literally exec’d!
Hints are written inside %{ %}. We already used them in the first example to assign a value to the input variable:
%{ input = 1 %}
This is completely equivalent to writing input = 1 in the main body of the program.
Hints are very powerful, but they are also very dangerous. If you are not careful, you can easily introduce security vulnerabilities into your program.
Consider the following program:
def main(input): if input == 1: print("One") else: print("Not one")
%{ input = 1 %}
This program is vulnerable to a type confusion attack. The attacker can provide an input that is not an int, and the program will print "Not one".
The reason this program is vulnerable is because of the way Cairo handles type errors. If Cairo encounters a type error, it will automatically generate a new path that is equivalent to the original path, but with the type error removed.
In this case, the type error is the comparison of an int and a non-int. Cairo will automatically generate a new path that is equivalent to the original path, but with the type error removed. This new path will always print "One", regardless of the input.
To avoid this type of vulnerability, you should always be careful to only use hints to write code that is type-safe.
Recursion and underconstrained structures Cairo has limited support for recursion, and the way it handles recursion can lead to unexpected results.
Consider the following program:
def main(input): if input == 1: print("One") else: main(input - 1)
%{ input = 1 %}
This program is vulnerable to a stack overflow attack. The attacker can provide an input that is not an int, and the program will recurse infinitely.
The reason this program is vulnerable is because of the way Cairo handles underconstrained structures. If Cairo encounters an underconstrained structure, it will automatically generate a new path that is equivalent to the original path, but with the underconstrained structure removed.
In this case, the underconstrained structure is the recursive call to main. Cairo will automatically generate a new path that is equivalent to the original path, but with the recursive call removed. This new path will always print "One", regardless of the input.
To avoid this type of vulnerability, you should always be careful to only use recursion when the input is fully constrained.
Non-deterministic jumps Cairo has limited support for non-deterministic jumps, and the way it handles non-deterministic jumps can lead to unexpected results.
Consider the following program:
def main(input): if input == 1: print("One") else: main(input - 1)
%{ input = 1 %}
This program is vulnerable to a non-deterministic jump attack. The attacker can provide an input that is not an int, and the program will non-deterministically print "One" or recurse infinitely.
The reason this program is vulnerable is because of the way Cairo handles non-deterministic jumps. If Cairo encounters a non-deterministic jump, it will automatically generate two paths: one path that is equivalent to the original path, and another path that is equivalent to the original path with the non-deterministic jump removed.
In this case, the non-deterministic jump is the recursive call to main. Cairo will automatically generate two paths: one path that recurses infinitely, and another path that prints "One".
To avoid this type of vulnerability, you should always be careful to only use non-deterministic jumps when the input is fully constrained.
But what happens if an error (or an off-by-one bug) occurs and causes the sqr_array function to be called with a zero length?
func main{output_ptr : felt*}(): alloc_locals # Allocate a new array. let (local array) = alloc() # Fill the new array with field elements. assert [array] = 1 assert [array + 1] = 2 assert [array + 2] = 3 assert [array + 3] = 4
let (new_array) = sqr_array(array=array, length=0)
serialize_word([new_array])
serialize_word([new_array + 1])
serialize_word([new_array + 2])
serialize_word([new_array + 3])
return ()
end
The program would crash when it tries to dereference a null pointer.
