Introduction

TinyRAM is a simple Reduced Instruction Set Computer (RISC) with byte-level addressable random-access memory and input tapes.

There are two variants of TinyRAM: one follows the Harvard (hv) architecture, and the other follows the von Neumann (vn) architecture (in this article, we mainly focus on the von Neumann architecture).

The Succinct Computational Integrity and Privacy Research (SCIPR) project construct mechanisms for proving the correct execution of TinyRAM programs, and TinyRAM is designed for efficiency in this setting. It strikes a balance between two opposing requirements — “sufficient expressibility” and “small instruction set”:

In this article, we will supplement the previous article rather than a repeating TinyRAM introduction, and then focus on the introduction of instructions and circuit constraints.

For a basic introduction to TinyRAM, please refer to our previous article: tinyram简介-中文 TinyRAM Review-English

TinyRAM Instruction Set

TinyRAM supports 29 instructions, each specified by an opcode and up to 3 operands. The operand can be either a name of a register (i.e., integers from 0 to K-1) or an immediate value (i.e., W-bit strings).

Unless otherwise specified, each instruction does not modify the flag separately and increments pc by i (i % 2^W) by default, i=2W/8 for the vnTinyRAM.

In general, the first operand is the target register for instruction computation and the other operands specify the parameters required by the instructions. Finally, all instructions take one cycle of the machine to execute.

Bit-related Operation

Integer-related Operation

These are variously unsigned and signed integer-related operations. In each setting, if an arithmetic overflow or error occurs (such as dividing by zero), set flag to 1 , and otherwise set it to 0.

In the following part, U_{W} represents a series of integers {0*,…,* $2^{W} -1 $}; These integers are made up of W-bit strings. S_{W} represents a series of integers {−2^(W−1),… 0, 1, $2 ^ $} {} W - 1-1. These integers are also made up of W-bit strings.

udiv instuction udiv ri rj A  will store the W-bit string a_{W−1}⋯a_{0} into the ri.

If [A]u = 0, a{W−1}⋯a_{0} = 0^W.

If [A]u∣ ≠ 0, a{W−1}⋯a_{0} is the only binary encoding of the integer Q, making that for some integers R ∈ {0,…,[A]_u − 1}, the formula [rj]_u = [A]_u × Q + R is workable. In addition, only when [A]_u=0flag  will be set to 1.

umod instruction umod ri rj A will store the W-bit string a_{W−1}⋯a_{0} into the ri.

If [A]u = 0, a{W−1}⋯a_{0} = 0^W.

If [A]u∣ ≠ 0, a{W−1}⋯a_{0} is the only binary encoding of the integer R, whose value range is

{0,…,[A]_u−1}, making the formula [rj]_u = [A]_u × Q+R wokable for some Q values. In addition, only when [A]_u = 0flag  will be set to 1.

Shift-related Operation

Compare-related Operation

Each instruction in the compare-related operation does not modify any registers; The comparison results will be stored in flag.

Move-related Operation

Jump-related

These jump and conditional jump instructions will not modify the register and flag, but will modify the pc.

Memory-related Operation

These are simple memory load operations and store operations, where the address of memory is determined by the immediate number or the contents of the register.

These are the only addressing methods in TinyRAM. (TinyRAM does not support the common “base+offset” addressing mode).

Input-related Operation

This instruction is the only one that can access either of the tapes. The 0th tape was used for primary input and the first tape for user auxiliary input.

Since TinyRAM only has two input tapes, all tapes except the first two are assumed to be empty.

Specifically, if [A]u is not 0 or 1, then we store 0^W in the ri and set flag=1.

Output-related Operation

This instruction means that the program has finished its computation and no other operations are allowed.

Constraint of Instruction Set

TinyRAM has used R1CS constraint to perform circuit constraints, and the specific form is as follows:

(Ai ∗ S) ∗ (Bi ∗ S) − Ci ∗ S = 0

Now, if we want to prove that we know a solution of the original computation, we will need to prove that in matrices A, B and C, every row vector and solution vector S of inner product value is accord with R1CS constraints A_i, B_i, C_i represents the row vector in the matrix).

Each R1CS constraint can be represented by linear_combination a, b, or c. Assignments of all variables in an R1CS system can be divided into two parts: primary input and auxiliary input. The Primary is what we often call a “statement” and the auxiliary is “witness”.

Each R1CS constraint system contains multiple R1CS constraints. The vector length for each constraint is fixed (primary Input size + auxiliary Input size + 1).

TinyRAM has used a lot of custom gadgtes in the libsnark code implementation to express vm constraints as well as opcode execution and memory constraints. The specific code is in the gadgetslib1/ Gadgets /cpu_checkers/tinyram folder.

Bit-related Constraint

Integer-related Operation Constraint

  B * q + r = A

  r <= B

‘B’ is the divisor, ‘q’ is the quotient, and ‘r’ is the remainder. The remainder must not exceed the divisor. The specific constraint codes are as follows:

  /* B_inv * B = B_nonzero */
        linear_combination<FieldT> a1, b1, c1;
        a1.add_term(B_inv, 1);
        b1.add_term(this->arg2val.packed, 1);
        c1.add_term(B_nonzero, 1);

        this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(a1, b1, c1), FMT(this->annotation_prefix, " B_inv*B=B_nonzero"));

        /* (1-B_nonzero) * B = 0 */
        linear_combination<FieldT> a2, b2, c2;
        a2.add_term(ONE, 1);
        a2.add_term(B_nonzero, -1);
        b2.add_term(this->arg2val.packed, 1);
        c2.add_term(ONE, 0);

        this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(a2, b2, c2), FMT(this->annotation_prefix, " (1-B_nonzero)*B=0"));

        /* B * q + r = A_aux = A * B_nonzero */
        linear_combination<FieldT> a3, b3, c3;
        a3.add_term(this->arg2val.packed, 1);
        b3.add_term(udiv_result, 1);
        c3.add_term(A_aux, 1);
        c3.add_term(umod_result, -;

        this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(a3, b3, c3), FMT(this->annotation_prefix, " B*q+r=A_aux"));

        linear_combination<FieldT> a4, b4, c4;
        a4.add_term(this->arg1val.packed, 1);
        b4.add_term(B_nonzero, 1);
        c4.add_term(A_aux, 1);

        this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(a4, b4, c4), FMT(this->annotation_prefix, " A_aux=A*B_nonzero"));

        /* q * (1-B_nonzero) = 0 */
        linear_combination<FieldT> a5, b5, c5;
        a5.add_term(udiv_result, 1);
        b5.add_term(ONE, 1);
        b5.add_term(B_nonzero, -1);
        c5.add_term(ONE, 0);

        this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(a5, b5, c5), FMT(this->annotation_prefix, " q*B_nonzero=0"));

        /* A<B_gadget<FieldT>(B, r, less=B_nonzero, leq=ONE) */
        r_less_B->generate_r1cs_constraints();

Compare Operation

Each instruction in the compare operations will not modify any register; The comparison results are stored in flag. Compare instructions include cmpecmpacmpaecmpg and cmpge, which can be divided into two types: signed number comparison and unsigned number comparison, both of which use the realized comparison_gadget of libsnark in their constraint process core.

Move Operation Constraint

The constraint condition of cmov is more complex than that of mov, because the mov behaviors are related to the change of flag value, and cmov will not modify flag, so the constraint needs to ensure that the value of flag does not change, and the code is as follows:

        /*
          flag1 * arg2val + (1-flag1) * desval = result
          flag1 * (arg2val - desval) = result - desval
        */
        this->pb.add_r1cs_constraint(
            r1cs_constraint<FieldT>(
                {this->flag},
                {this->arg2val.packed, this->desval.packed * (-1)},
                {this->result, this->desval.packed * (-1)}),
            FMT(this->annotation_prefix, " cmov_result"));

        this->pb.add_r1cs_constraint(
            r1cs_constraint<FieldT>(
                {ONE},
                {this->flag},
                {this->result_flag}),
            FMT(this->annotation_prefix, " cmov_result_flag"));

Jump Operation Constraint

These jump and conditional jump instructions will not modify the registers and flag but will modify pc.

cjmp will jump according to the flag condition when flag = 1, and otherwise increments pc by 1.

The constraint formula is as follows:

      flag1 * argval2 + (1-flag1) * (pc1 + 1) = cjmp_result
      flag1 * (argval2 - pc1 - 1) = cjmp_result - pc1 - 1

The constraint code is as follows:

    this->pb.add_r1cs_constraint(
        r1cs_constraint<FieldT>(
            this->flag,
            pb_packing_sum<FieldT>(pb_variable_array<FieldT>(this->argval2.bits.begin() + this->pb.ap.subaddr_len(), this->argval2.bits.end())) - this->pc.packed - 1,
            this->result - this->pc.packed - 1),
        FMT(this->annotation_prefix, " cjmp_result"));

 flag1 * (pc1 + 1) + (1-flag1) * argval2 = cnjmp_result
 flag1 * (pc1 + 1 - argval2) = cnjmp_result - argval2

The constraint code is as follows:

 this->pb.add_r1cs_constraint(
        r1cs_constraint<FieldT>(
            this->flag,
            this->pc.packed + 1 - pb_packing_sum<FieldT>(pb_variable_array<FieldT>(this->argval2.bits.begin() + this->pb.ap.subaddr_len(), this->argval2.bits.end())),
            this->result - pb_packing_sum<FieldT>(pb_variable_array<FieldT>(this->argval2.bits.begin() + this->pb.ap.subaddr_len(), this->argval2.bits.end()))),
        FMT(this->annotation_prefix, " cnjmp_result"));

Memory Operation Constraint

These are simple memory load and store operations, where the address of memory is determined by the immediate number or the contents of a register.

These are the only addressing methods in TinyRAM. (TinyRAM does not support the common “base+offset” addressing mode).

Input Operation Constraint

  1. When the previous tape is finished and there are contents to read, the next tape is not allowed to be read.

  2. When the previous tape is finished and there are contents to read, the flag is set to 1

  3. If the read instruction is exected now, then the content read catches is consistent with the input content of the tape

  4. Read content from outside of type1, flag  is set to 1

  5. Whether the result is 0 means the flag is 0

Constraint code:

this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(prev_tape1_exhausted,
                                                     1 - next_tape1_exhausted,
                                                     0),
                             FMT(this->annotation_prefix, " prev_tape1_exhausted_implies_next_tape1_exhausted"));
this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(prev_tape1_exhausted,
                                                     1 - instruction_flags[tinyram_opcode_READ],
                                                     0),
                             FMT(this->annotation_prefix, " prev_tape1_exhausted_implies_flag"));
this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(opcode_indicators[tinyram_opcode_READ],
                                                     1 - arg2val->packed,
                                                     read_not1),
                             FMT(this->annotation_prefix, " read_not1")); 
this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(read_not1,
                                                     1 - instruction_flags[tinyram_opcode_READ],
                                                     0),
                             FMT(this->annotation_prefix, " other_reads_imply_flag"));
this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(instruction_flags[tinyram_opcode_READ],
                                                     instruction_results[tinyram_opcode_READ],
                                                     0),
                             FMT(this->annotation_prefix, " read_flag_implies_result_0"));

Output Operation Constraints

This instruction indicates that the program has completed its computation and therefore no further operations are allowed

When the program’s output value is accepted, has_accepted is set to 1, and the program’s return value is accepted normally, meaning that the current instruction is answer and arg2 value is 0.

Constraint code is as follows:

    this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(next_has_accepted,
                                                         1 - opcode_indicators[tinyram_opcode_ANSWER],
                                                         0),
                                 FMT(this->annotation_prefix, " accepting_requires_answer"));
    this->pb.add_r1cs_constraint(r1cs_constraint<FieldT>(next_has_accepted,
                                                         arg2val->packed,
                                                         0),
                                 FMT(this->annotation_prefix, " accepting_requires_arg2val_equal_zero"));

Other

Of course, in addition to some of the instruction-related constraints mentioned above, TinyRAM also has some constraints on pc consistency, parameter codec, memory checking, etc. These constraints are combined through the R1CS system to form a completed TinyRAM constraint system. Therefore, this is the root cause why a large number of TinyRAM constraints are generated in the form of R1CS.

Here we refer to a figure of tinyram review ppt, showing the time consumption required for an ERC20 transfer to generate a proof via TinyRAM.

It can be concluded from the example above: it is not possible to verify all EVM operations with vnTinyRam + zk-SNARks and it is only suitable for computational verification of a small number of instructions. The vnTinyram can be used to verify opcode for partial computation types of EVM.