Aleo is a blockchain platform that utilizes zero-knowledge cryptography to enable private and scalable decentralized applications. Central to Aleo is Leo, a high-level programming language tailored for developing private applications. Leo allows developers to focus on creating applications with strong privacy without needing to consider the intricacies of zero-knowledge proofs.
Understanding and utilizing the unique features of Leo is essential for developers aiming to build robust and secure solutions. This article provides a brief introduction to Leo, with a focus on its security features and practical tips for developers. It aims to help developers understand how to leverage Leo’s capabilities to write more secure programs on Aleo. All the programs in this article are written in Leo version 2.1.0.
Leo Language Basics
Leo is a statically typed language with similar syntax to Rust. Below is an example program that would give you a first impression on Leo language:
program example_program1.aleo {
mapping data_map: address => u8;
// transition is executed off-chain
async transition save_sum(private a: u8, private b: u8) -> (u8, Future) {
let sum: u8 = a + b;
return (sum, finalize_save_sum(self.caller, sum));
}
// finalize is executed on-chain
async function finalize_save_sum(caller: address, sum: u8) {
data_map.set(caller, sum);
}
}
The syntax of Leo is quite straightforward. In the example_program1.aleo
program above, it defines a map data_map
that stores key as address
type and value as u8
type. In the save_sum
transition, it takes two inputs, calculates the sum and passes it to finalize_save_sum
function. Then, in the finalize_save_sum
function, it sets the sum value to the data_map
under the key of the caller address.
The unique part of Leo is that it separates transition
and function
. The transition
is executed off-chain with privacy. The correctness of the execution is proved by a zero-knowledge proof and verified by the network. All the inputs (a
and b
) and the intermediate variables are hidden by default. The function
is executed publicly on-chain so that it can read and write the public state on-chain.
Record Model
As the transition
can only be executed off-chain, it cannot touch on-chain states. Instead, it can create and consume Record
that encapsulate the state and data of a contract. The Record
is a special struct that represents UTXO data model on Aleo. A Record
can have self-defined fields. The private fields are encrypted and stored on the ledger. Only the creator and owner are able to decrypt the private fields. A transition
can create a record by outputting it and consume a record by inputting it.
program example_program2.aleo {
record Token {
owner: address,
amount: u128
}
transition private_transfer_token(private receiver: address, private token: Token) -> Token {
let new_token: Token = Token {
owner: receiver,
amount: token.amount
};
return new_token;
}
}
In the code above, it defines a record called Token
with owner
and amount
fields. The private_transfer_token
transition takes Token
record as input, consumes it, and outputs a new token with the same amount and a new owner. All the fields in the Token
record are private by default. As private_transfer_token
is executed off-chain and the input and output is private, others won’t know the sender, receiver, and amount.
Potential Vulnerabilities of Aleo Program
Aleo provides simple and powerful primitives for building private applications. Nonetheless, it may introduce unintuitive behavior and vulnerabilities without caution. In this section we look at the coding patterns and potential vulnerabilities of Aleo program.
Underflow and Overflow
For integer types (e.g., i8
andu8
), underflow and overflow will always be caught in Leo. In a transition
, the prover won’t be able to create a proof if underflow or overflow happens. In a function
, the whole transaction will be reverted if that happens.
For example, in the previous example_program1.aleo
program, it will panic if we run the save_sum
with 128u8
and 128u8
.
$ leo run save_sum 128u8 128u8
Leo ✅ Compiled 'example_program1.aleo' into Aleo instructions
Failed constraint at :
(256 * 1) != 0
Error [ECLI0377012]: Failed to execute the `run` command.
Error: 'example_program1.aleo/save_sum' is not satisfied on the given inputs (14652 constraints).
Also, an impossible cast will cause panic:
// input a as 256 will panic because it cannot cast
transition cast_number(private a: u32) -> u8 {
return a as u8;
}
For field
type, there won’t be underflow and overflow because it is modular arithmetic.
program example_program3.aleo {
transition sub_field(private a: field, private b: field) -> field {
return a - b;
}
}
Run the program above with sub_field(0field,1field)
will get the modulo result:
$ leo run sub_field 0field 1field
Leo ✅ Compiled 'example_program3.aleo' into Aleo instructions
⛓ Constraints
• 'example_program3.aleo/sub_field' - 0 constraints (called 1 time)
➡️ Output
• 8444461749428370424248824938781546531375899335154063827935233455917409239040field
Leo ✅ Finished 'example_program3.aleo/sub_field'
Program Initialization
Leo does not provide a default function to initialize a program after deployment (like constructor
function in Solidity). This means the developer need to provide the initialize function on their own. It is crucial that the initialization function is properly guarded to prevent unauthorized call or repeated call.
In the program below, the initialize
can be repeatedly called. Then anyone can call the function to set themselves as admin and then successfully call mint
function.
program example_program4.aleo {
const ADMIN_KEY: u8 = 0u8;
mapping admin: u8 => address;
// This is NOT safe!
async transition initialize() -> Future {
return finalize_initialize(self.caller);
}
async function finalize_initialize(caller: address) {
admin.set(ADMIN_KEY, caller);
}
async transition mint() -> Future {
return finalize_mint(self.caller);
}
async function finalize_mint(caller: address) {
let current_admin: address = admin.get(ADMIN_KEY);
assert(current_admin == caller);
// mint token
}
}
Record Can Only Be Consumed by The Program Defines It
In an Aleo program, a Record
can be defined in program A and passed into program B. However, it is restricted that the Record
can only be created or consumed by the program that defines it (i.e., program A).
For example, if we want to implement a program that burns a credits record and issues a certificate, the following program will not burn the credit record.
import credits.aleo;
program example_program5.aleo {
record BurnerCertificate {
owner: address,
amount: u64
}
// credit won't be consumed in this transition
transition burn(credit: credits.aleo/credits) -> BurnerCertificate {
let certificate: BurnerCertificate = BurnerCertificate {
owner: credit.owner,
amount: credit.microcredits
};
return certificate;
}
}
The burn
transition takes credits.aleo/credits
as input. However, the credit won’t be consumed in the transition because it is not defined in the current program (i.e., the credit is an external record).
The right way is to call a function in credits.aleo
to burn the credit:
transition burn(credit: credits.aleo/credits) -> BurnerCertificate {
let certificate: BurnerCertificate = BurnerCertificate {
owner: credit.owner,
amount: credit.microcredits
};
// call `credits.aleo/transfer_private` to burn credit
credits.aleo/transfer_private(credit, ZERO_ADDRESS, credit.microcredits);
return certificate;
}
In the code above, the credit
record is passed into credits.aleo/transfer_private
. The record will be burned because the record and function are defined in the same program.
Record Transferred to Program Will be Lost
When a Record
is created, its private fields are encrypted using the owner’s address secret key. When the owner consumes the Record
, it needs to be authorized using the owner’s private key. As a program doesn’t have a private key, it’s impossible for it to consume a Record
. This means that any Record
transferred to a program will be lost.
The developer should handle this carefully to ensure that the record won’t be sent to a program. Currently, there is no direct way to tell if an address is a program address except that it requires the owner to be self.signer
.
Leak Information in Function
In an Aleo program, function
is executed on-chain and is totally public. The developer should be cautious about what is passed to the function
.
The code below is an example that may leak information about the Record
:
program example_program6.aleo {
record Token {
owner: address,
amount: u128,
expire_at: u32
}
async transition transfer(private receiver: address, private token: Token) -> (Token, Future) {
let new_token: Token = Token {
owner: receiver,
amount: token.amount,
expire_at: token.expire_at
};
return (new_token, finalize_transfer(token.expire_at));
}
// It may leak information because the `expire_at` field is public.
async function finalize_transfer(expire_at: u32) {
assert(expire_at > block.height);
}
}
In the transfer
transition, it will pass the expire_at
field of the Token
into the finalize_transfer
function. If some records have distinct expire_at
values, others will be able to reason about which Token
is being transferred.
One way to mitigate this issue is to avoid passing the exact expire_at
field to the function. Instead, we can input an intermediate value intermediate_height
and make sure intermediate_height < height
and token.expire_at <= intermediate_height
in the transition:
program example_program6.aleo {
record Token {
owner: address,
amount: u128,
expire_at: u32
}
async transition transfer(private receiver: address, private token: Token, intermediate_height: u32) -> (Token, Future) {
assert(token.expire_at >= intermediate_height);
let new_token: Token = Token {
owner: receiver,
amount: token.amount,
expire_at: token.expire_at
};
return (new_token, finalize_transfer(intermediate_height));
}
// Only compare the `intermediate_height` in the public function
async function finalize_transfer(intermediate_height: u32) {
assert(intermediate_height > block.height);
}
}
The Ternary Conditional Operator Will Evaluate Both Sides
Leo supports the ternary conditional operator. However, the conditional will evaluate both sides. Consider the following code to calculate the absolute value of sub:
program example_program7.aleo {
transition abs_sub(private a: u32, private b: u32) -> u32 {
return a > b ? a - b : b - a;
}
}
This function will always panic when a != b
. This is because both a - b
and b - a
will be evaluated and one of them will underflow:
$ leo run abs_sub 2u32 1u32
Leo ✅ Compiled 'example_program7.aleo' into Aleo instructions
Failed constraint at :
(0 * 1) != 1
Error [ECLI0377012]: Failed to execute the `run` command.
Error: 'example_program7.aleo/abs_sub' is not satisfied on the given inputs (13834 constraints).
To avoid the underflow, we can cast the unsigned integer to a signed integer. Then, cast the result back to unsigned integer:
transition abs_sub(private a: u32, private b: u32) -> u32 {
// use i64 to ensure a successful cast
let ai: i64 = a as i64;
let bi: i64 = b as i64;
let ci: i64 = ai > bi ? ai - bi : bi - ai;
return ci as u32;
}
Program Identified by Name Might Cause Issues
In Aleo, a program is identified by its program name, regardless of the program content. A program imports another program by name. This means the developer should verify the program manually after deploying it.
For example, suppose you want to deploy a multisig program (let’s say my_secure_multisig.aleo
) to hold your funds. The usual steps are first deploying the my_secure_multisig.aleo
program and then sending funds to the program by name. However, the attacker may be able to front-run your deployment and deploy a program with the same name but totally different content. Then the my_secure_multisig.aleo
program will be controlled by the attacker. You will immediately lose all the funds once you send them.
Another example is about program import. Suppose that you have two programs: programA.aleo
and programB.aleo
. programA.aleo
imports programB.aleo
. You need to first deploy programB.aleo
and then deploy programA.aleo
. It is possible that an attacker front-run to deploy a fake programB.aleo
before your transaction. Then your programA.aleo
will import the fake programB.aleo
. This will make the whole program unsafe.
To avoid such attacks, we recommend always verifying if the program is deployed by you after your deployment transaction.
Conclusion
As a new blockchain platform, Aleo provides simple yet powerful primitives for building private applications. Nevertheless, these new primitives may introduce unintuitive behavior and vulnerabilities without caution. In this article, we discussed the potential vulnerabilities of Aleo programs. As Aleo evolves, we will keep an eye on its developments and continue to provide insights on maintaining security in this innovative space.