Certora Verification Language for Rust (CVLR)

CVLR stands for Certora Verification Language for Rust. It is pronounced like “cavalier”. CVLR provides the basic primitives for writing verification rules that specify pre- and post-conditions for client code. Unlike CVL for Solidity, CVLR is embedded in Rust. It is compiled by the Rust compiler and has simple operational semantics.

Assertions

The simplest feature of CVLR is assertions. An assertion is written as cvlr_assert!(cond), where cond is the condition being asserted. The semantics is the same as traditional asserts in Rust – an assertion is violated if there is (a panic-free) execution that reaches cvlr_assert!(cond) and in the current execution state cond is false.

For example, cvlr_assert!(true) is never violated, while cvlr_assert!(false) is always violated when reached.

What makes cvlr_assert!() special is that it is verified symbolically by the Certora Prover. That is, the Prover returns Violated if there is an input and an execution of the program that reaches that assertion and violates it.

Often, the intended meaning of an assertion is to not be violated, i.e., to hold on all possible executions. However, sometimes it is useful to check whether some execution is possible. In that case, the specification intends for the assertion to be reachable. For such cases, CVLR provides a satisfy assertion, called cvlr_satisfy!(cond).

The semantics of cvlr_satisfy!(cond) is that it is Violated when it is either not reached, or when every execution that reaches it, violates the condition. For example, cvlr_satisfy!(true) is violated only if it is never reachable (i.e., part of dead code), and cvlr_satisfy!(false) is always violated.

Assumptions

Assumptions provide a way to restrict input to reflect some pre-condition. CVLR provides an assumption macro cvlr_assume!(cond). If an execution reaches cvlr_assume!(cond), it continues only if cond is true in the current program state. Otherwise, the execution aborts.

For example, cvlr_assume!(true) is a noop, while cvlr_assume!(false) blocks all executions that reach it.

A typical use of cvlr_assume! is to restrict a range of a value beyond the restriction afforded by its type. For example, restricting the maximum value of a variable to 100 can be done as follows:

let fee_bps = get_some_fee();
cvlr_assume!(fee_bps <= 100)
// computation reaches here only if fee_bps is no greater than 100

Nondeterministic Values

A specification must tell the Prover what are the (symbolic) inputs that the Prover has to explore. Such values are called non-deterministic. The name comes from the fact that the Prover chooses the values non-deterministically (i.e., not following any specific pre-determined exploration scheme or probability distribution). Nondet values are similar to arbitrary values in property-based testing, except that they are not chosen randomly, but are explored exhaustively via symbolic reasoning.

CVLR provides a generic function nondet() that can generate non-deterministic values of all primitive integers. For example, nondet::<u64>() returns a non-deterministic u64, and nondet::<u16>() returns a non-deterministic u16.

CVLR Rules

Specifications are written as pre- and post-conditions in rules. A rule is similar to a unit test. However, instead of being executed for some specific input, the rule is symbolically analyzed for all possible non-deterministic values.

In CVLR, rules are regular Rust functions, annotated with #[rule].

A complete example of a specification with several rules is shown below. The function being verified is compute_fee. We have included it in the spec file for simplicity.

use cvlr::{nondet, asserts::*, cvlr_rule as rule, clog};

pub fn compute_fee(amount: u64, fee_bps: u16) -> Option<u64> {
    if amount == 0 { return None; }
    let fee = amount.checked_mul(fee_bps).checked_div(10_000);
    Some(fee)
}

#[rule]
pub fn rule_fee_sanity() {
   compute_fee(nondet(), nondet()).unwrap();
   cvlr_satisfy!(true); 
}

#[rule]
pub fn rule_fee_assessed() {
    let amt: u64 = nondet();
    let fee_bps: u16 = nondet();
    cvlr_assume!(fee_bps <= 10_000);
    let fee = compute_fee(amt, fee_bps).unwrap();
    clog!(amt, fee_bps, fee);
    cvlr_assert!(fee <= amt);
    if fee_bps > 0 { cvlr_assert!(fee > 0); }
}

#[rule]
pub fn rule_fee_liveness() {
    let amt: u64 = nondet();
    let fee_bps: u16 = nondet();
    cvlr_assume!(fee_bps <= 10_000);
    let fee = compute_fee(amt, fee_bps);
    clog!(amt, fee_bps, fee);
    if fee.is_none() { cvlr_assert!(amt == 0); }
}

The rule rule_fee_sanity checks that the function under verification has at least one panic-free execution. A rule like that is called a sanity rule. It is a good practice to start a specification with such a rule.

The rule rule_fee_assessed checks that a fee can be computed for an arbitrary amount. It has two assertions. The first checks that the fee is never greater than the amount. The second checks that the fee is always assessed when required. The first assertion is not violated. However, the second is. Can you spot the bug? Note that we have also added calls to the log macro clog! that is similar to the dbg! macro in Rust. The clog! macro will instruct the Prover to produce the values of the desired variables in any violating execution.

The rule rule_fee_liveness checks that the fee is always computed, except when the fee rate is 0. This assertion is violated. Can you spot the bug?