Interfacing the ZK-SecreC Compiler with ZK Backends

When the ZK-SecreC compiler compiles a ZK-SecreC program, it can produce a circuit in a certain IR but it can also call a ZK backend (which executes the ZK protocols) directly. Support for new backends (either backends producing a different IR or backends executing the ZK protocols) can be added to the ZK-SecreC compiler by implementing a certain Rust trait for that backend.

The trait is called SIEVEIR and is defined in src/Rust/zksc-app-lib/src/sieve.rs.

The dummy backend

There is a simple implementation of the SIEVEIR trait that can be used as an example when implementing the trait for a new backend. This simple backend is called the dummy backend and is implemented in src/Rust/zksc-dummy/src/main.rs. It runs the circuit operations locally instead of executing ZK protocols or generating IR. It also makes many correctness checks but is not optimized for efficiency, so it may be slow.

The trait for wires

There is a notion of wire, meaning a scalar value that is on a circuit, i.e. a scalar value that is in $post stage. Each backend may handle wires differently. For example, backends producing an IR may have unique number for each wire, while backends executing ZK protocols may have a certain structure in memory to represent each wire.

There is a trait WireTrait, defined in sieve.rs, that must be implemented for each backend according to how it represents wires internally.

There is also a struct Wire, which is a trait object of the trait WireTrait. Objects of the type Wire are given as arguments and returned from many methods of the SIEVEIR trait.

Implementing WireTrait for a datatype that represents the wire internally is not difficult. The datatype must derive the Debug trait and implement WireTrait as follows (where WireImpl is the datatype representing the wire):

impl WireTrait for WireImpl {
    fn to_any(&self) -> &dyn Any {
        self as &dyn Any
    }
}

The implementation of to_any is the same for each datatype but it cannot be made into a default implementation because then it would convert the polymorphic type (&dyn WireTrait) rather than the monomorphic type (&WireImpl) into &dyn Any and then it cannot be converted back to the monomorphic type &WireImpl.

When implementing methods of the SIEVEIR trait that take as argument or return objects of type Wire, it is usually necessary to convert the objects from type Wire to the concrete type implementing WireTrait, as well as back from the concrete type to Wire. Converting from the concrete type to Wire can be done using the polymorphic function upcast_wire, defined in sieve.rs. The function that converts from Wire to the concrete type needs to be defined for each concrete type separately as follows:

fn downcast_wire(w: &Wire) -> &WireImpl {
    w.downcast::<WireImpl>()
}

where WireImpl is the concrete type implementing WireTrait. There is also a function upcast_wires (and downcast_wires if defined for the given type), converting vectors of wires instead of single wires.

Wire ranges

There is also a notion of wire range, meaning a vector of wires, i.e. a vector of scalar values in the $post stage. Wire ranges may also be handled differently in each backend. For example, backends producing an IR may have a contiguous range of numbers for each wire in the wire range, while backends executing ZK protocols may have some structure in memory that is more efficient than having a separate data structure for each wire in the wire range.

There is a trait WireRangeTrait, defined in sieve.rs, that must be implemented for each backend that supports wire ranges. Supporting wire ranges is not compulsory but is recommended for efficiency. The ZK-SecreC compiler supports automatic unrolling of vectorized operations, which replaces wire ranges with the individual wires in the range, and can be used for backends that do not support wire ranges.

Similarly to wires, there is also a struct WireRange, which is a trait object of the trait WireRangeTrait. Objects of the type WireRange are given as arguments and returned from several methods of the SIEVEIR trait.

Implementing WireRangeTrait for a datatype that represents the wire internally is similar to implementing WireTrait. The datatype must derive the Debug trait and implement the method to_any in the same way as for WireTrait. In addition, it must implement the method length that returns the number of wires in a wire range.

Converting from a concrete type implementing WireRangeTrait to WireRange and back is similar to wires, using upcast_wr, defined in sieve.rs, and downcast_wr, defined for each concrete type as follows:

fn downcast_wr(wr: &WireRange) -> &WireRangeImpl {
    wr.downcast::<WireRangeImpl>()
}

where WireRangeImpl is the concrete type implementing WireRangeTrait. There is also a function upcast_wrs (and downcast_wrs if defined for the given type), converting vectors of wire ranges instead of single wire ranges.

Types used in SIEVEIR method signatures

Many of the arguments and return values of the methods of the SIEVEIR trait are of types that are defined in the Rust part of the ZK-SecreC compiler, rather than standard Rust types.

The types Wire and WireRange were described above.

The type IndexRange is described under the method slice_wire_range, which is the only method where it is used.

The other types are described here.

The type Integer

Integer is a type synonym (defined in integer.rs) for BigInt from num_bigint. It is used for integer constant arguments of SIEVEIR methods.

The type Value

The type Value represents any ZK-SecreC value but here it is assumed to be a scalar (integer or boolean) $pre value. It is used for values that are added to instance or witness streams (and from there to wires), or for public constants loaded directly onto wires. It is a reference-counted object that can be cloned in constant time to turn &Value into Value if necessary.

It is possible to branch over whether the value v: &Value with modulus m: &NatType is an integer or a boolean and extract the value as a bool or a BigInt, using

match v.view() {
    ValueView::ZkscDefined() => { /* integer with value n: BigInt */
    let n: BigInt = (m.to_bigint)(v);
    ...
    }
    ValueView::Bool(b) => { /* boolean with value b: bool */
    ...
    }
    _ => panic!("Not an integer or boolean"),
}

The type NatType is described in the next section. Here we use the method to_bigint of the modulus m: &NatType. Other methods of NatType can also be used to handle values of type Value. The full definition of Value (in value.rs) is complex and is not needed to implement the SIEVEIR trait, so it will be omitted here.

The type NatType

NatType denotes a modulus of wires and values, including inputs and outputs of sieve functions and plugins. It is defined in zksc_types.rs as a struct with methods as its fields:

pub struct NatType {
    pub tag: u64,
    pub modulus: Option<BigInt>,
    pub modulus_value: fn() -> Value,
    pub modulus_limbs_le: Option<&'static [usize]>,
    pub is_zero : fn(&Value) -> bool,
    pub is_one : fn(&Value) -> bool,
    pub eq : fn(&Value, &Value) -> bool,
    pub lt : fn(&Value, &Value) -> bool,
    pub add : fn(&Value, &Value) -> Value,
    pub mul : fn(&Value, &Value) -> Value,
    pub sub : fn(&Value, &Value) -> Value,
    pub div: fn(&Value, &Value) -> Value,
    pub hmod: fn(&Value,&Value) -> Value,
    pub mod_div : fn(&Value,&Value) -> Value,
    pub to_bigint: fn(&Value) -> Integer,
    pub from_bigint: fn(&Integer) -> Value,
    pub fmt: fn(&Value, &mut fmt::Formatter<'_>) -> fmt::Result,
    pub from_limbs_le: fn(&[usize]) -> Value,
}

For implementing the SIEVEIR trait, only the method to_bigint and the fields modulus and tag are useful.

The method to_bigint was used in the previous section about Value and extracts the integer from a &Value.

The field modulus contains Some(m) where m is the modulus as a BigInt if the modulus is finite. If the modulus is infinite then modulus is None but this should not occur with the moduli given to SIEVEIR trait methods since these moduli are used in the circuit.

The field tag is different for each modulus used in the ZK-SecreC program and can be used to check more efficiently whether two moduli are equal by comparing the u64 tags instead of the BigInt moduli.

The type WireOrConst

The enum WireOrConst is used for creating a vector or returning values from a function and is defined in sieve.rs as follows:

pub enum WireOrConst<'a> {
    W(&'a Wire),
    C(Integer),
}

allowing each value in the vector or returned from a function to either be copied from an existing wire or be a constant.

The type Allocation

The type Allocation is a trait object of the trait AllocationTrait (defined in sieve.rs), similarly to Wire and WireRange. It is used for backends that support allocating a wire range without immediately assigning values to the wires in the range. The following SIEVEIR method calls that return new wires will take those wires from this allocated range until all wires of the range have been assigned values. The wire range can be deallocated as a whole when all wires in the range have gone out of scope.

Implementing AllocationTrait and the methods that use the type Allocation is optional.