Este artículo explica cómo convertir un conjunto de restricciones aritméticas en un Rank One Constraint System (R1CS).
El enfoque de este recurso es la implementación: cubrimos muchos más casos extremos de esta transformación que otros materiales, discutimos optimizaciones y explicamos cómo la biblioteca Circom lo lleva a cabo.
Requisitos previos
- Asumimos que el lector entiende cómo usar circuitos aritméticos (circuitos zk) para representar la validez de un cálculo.
- El lector está familiarizado con la aritmética modular. Todas las operaciones aquí ocurren en un campo finito, por lo que realmente significa el inverso aditivo de módulo y significa el inverso multiplicativo de módulo multiplicado por .
Resumen de Rank 1 Constraint System
Un Rank 1 Constraint System (R1CS) es un circuito aritmético con el requisito de que cada restricción de igualdad tenga una multiplicación (y ninguna restricción en el número de sumas).
Esto hace que la representación del circuito aritmético sea compatible con el uso de emparejamientos bilineales (bilinear pairings). La salida de un emparejamiento no puede ser emparejada de nuevo, ya que un elemento en no puede ser utilizado como parte de la entrada de otro emparejamiento. Por lo tanto, solo permitimos una multiplicación por restricción.
El vector witness
En un circuito aritmético, el witness es una asignación a todas las señales que satisface las restricciones de la ecuación.
En un Rank 1 Constraint System, el vector witness es un vector de que contiene el valor de todas las variables de entrada, la variable de salida y los valores intermedios. Demuestra que has ejecutado el circuito de principio a fin, conociendo tanto la entrada, la salida, como todos los valores intermedios.
Por convención, el primer elemento siempre es 1 para facilitar algunos cálculos, lo cual demostraremos más adelante.
Por ejemplo, si tenemos la restricción
para la cual afirmamos conocer la solución, entonces eso debe significar que conocemos , y . Debido a que los Rank One Constraint Systems requieren exactamente una multiplicación por restricción, las restricciones del polinomio anterior deben escribirse como
Un witness significa que no solo conocemos , y , sino que debemos conocer cada variable intermedia en esta forma expandida. Específicamente, nuestro witness es un vector:
donde cada término tiene un valor que satisface las restricciones anteriores.
Por ejemplo,
es un witness válido porque cuando sustituimos los valores,
satisface las restricciones
El término 1 adicional no se usa en este ejemplo y es una conveniencia que explicaremos más adelante.
Ejemplo 1: Transformando en un Rank 1 Constraint System
Para nuestro ejemplo, diremos que estamos probando que .
Por lo tanto, nuestro vector witness es como una asignación a .
Antes de que podamos crear un R1CS, nuestras restricciones deben tener la forma
result = left_hand_side × right_hand_side
Afortunadamente para nosotros, ya lo está
Este es obviamente un ejemplo trivial, pero los ejemplos triviales suelen ser un excelente punto de partida.
Para crear un R1CS válido, necesitas una lista de fórmulas que contengan exactamente una multiplicación.
Más adelante discutiremos cómo manejar casos que no tienen exactamente una multiplicación, como o .
Nuestro objetivo es crear un sistema de ecuaciones de la forma:
Donde , y son matrices de tamaño x ( filas y columnas).
La matriz codifica la variable en el lado izquierdo de la multiplicación y codifica las variables en el lado derecho de la multiplicación. codifica las variables de resultado. El vector es el vector witness.
Específicamente, , y son matrices con el mismo número de columnas que el vector witness , y cada columna representa la misma variable que utiliza el índice.
Así que para nuestro ejemplo, el witness tiene 4 elementos , por lo que cada una de nuestras matrices tendrá 4 columnas, entonces .
El número de filas corresponderá al número de restricciones en el circuito. En nuestro caso, solo tenemos una restricción: , por lo que solo tendremos una fila, entonces .
Saltemos a la respuesta y expliquemos cómo la obtuvimos.
En este ejemplo, cada elemento en la matriz sirve como una variable indicadora de si la variable a la que corresponde la columna está presente o no. (Técnicamente, es el coeficiente de la variable, pero llegaremos a eso más adelante).
Para los términos del lado izquierdo, es la única variable presente en el lado izquierdo de la multiplicación, así que si las columnas representan , entonces…
es , porque está presente y ninguna de las otras variables lo está.
es porque la única variable en el lado derecho de la multiplicación es , y
es porque solo tenemos la variable en la “salida” de la multiplicación.
No tenemos ninguna constante en ninguna parte, por lo que la columna del 1 es cero en todas partes (discutiremos cuándo no es cero más adelante).
Esta ecuación es correcta, lo cual podemos verificar en Python
import numpy as np
# define the matrices
O = np.matrix([[0,1,0,0]])
L = np.matrix([[0,0,1,0]])
R = np.matrix([[0,0,0,1]])
# witness vector
a = np.array([1, 4223, 41, 103])
# Multiplication `*` is element-wise, not matrix multiplication.
# Result contains a bool indicating an element-wise indicator that the equality is true for that element.
result = np.matmul(O, a) == np.matmul(L, a) * np.matmul(R, a)
# check that every element-wise equality is true
assert result.all(), "result contains an inequality"
Puede que te estés preguntando cuál es el punto de esto, ¿no estamos simplemente diciendo que de una manera mucho menos compacta?
Estarías en lo correcto.
Un R1CS puede ser bastante verboso, pero se mapean perfectamente a Quadratic Arithmetic Programs (QAPs), los cuales pueden hacerse sucintos. Pero no nos ocuparemos de los QAPs aquí.
Pero este es un punto importante del R1CS. Un R1CS comunica exactamente la misma información que las restricciones aritméticas originales, pero con solo una multiplicación por restricción de igualdad. En este ejemplo, solo tenemos una restricción, pero agregaremos más en el siguiente ejemplo.
Ejemplo 2: Transformando r = x * y * z * u
En este ejemplo ligeramente más complicado, ahora tenemos que lidiar con variables intermedias. Cada fila de nuestro cálculo solo puede tener una multiplicación, por lo que debemos dividir nuestra ecuación de la siguiente manera:
No hay ninguna regla que diga que teníamos que dividirlo así, lo siguiente también es válido:
Usaremos la primera transformación para este ejemplo.
Tamaño de , y
Debido a que estamos lidiando con 7 variables , nuestro vector witness tendrá ocho elementos (siendo el primero la constante 1) y nuestras matrices tendrán ocho columnas.
Debido a que tenemos tres restricciones, las matrices tendrán tres filas.
Términos del lado izquierdo y términos del lado derecho
Este ejemplo reforzará fuertemente la idea de un “término del lado izquierdo” y un “término del lado derecho”. Específicamente, , y son términos del lado izquierdo, e , y son términos del lado derecho.
Construyendo la matriz a partir de los términos del lado izquierdo
Construyamos la matriz A. Sabemos que tendrá tres filas (ya que hay tres restricciones) y ocho columnas (ya que hay ocho variables).
Nuestro vector witness se multiplicará por esto, así que definamos nuestro vector witness para que tenga el siguiente diseño:
Esto nos informa qué representan las columnas de :
Primera fila de
En la primera fila, para la primera variable izquierda, tenemos :
Esto significa que, con respecto al lado izquierdo, la variable está presente y ninguna otra variable está presente. Por lo tanto, transformamos la primera fila de la siguiente manera:
Recuerda que las columnas de están etiquetadas de la siguiente manera:
y vemos que el está en la columna de .
Segunda fila de
Continuando hacia abajo, vemos que solo está presente para el lado izquierdo de nuestro sistema de ecuaciones.
Por lo tanto, establecemos todo en esa fila como cero, excepto la columna que representa .
Tercera fila de
Finalmente, tenemos a como la única variable presente en los operadores del lado izquierdo en la tercera fila
Esto completa la matriz
La siguiente imagen debería hacer más claro el mapeo:
Transformación alternativa de
Podríamos lograr este mismo ejercicio expandiendo los valores del lado izquierdo de
como
Podemos hacer eso porque agregar términos cero no cambia los valores. Solo tenemos que ser cuidadosos de expandir las variables cero para que tengan las mismas “columnas” que como hemos definido el vector witness.
Y luego, si extraemos los coeficientes (mostrados en recuadros) de la expansión anterior,
Obtenemos la misma matriz para que generamos hace un momento.
Construyendo la matriz a partir de los términos del lado derecho
La matriz representa los términos del lado derecho de nuestra ecuación:
La matriz debe tener unos (1s) que representen a , y . La fila en la matriz corresponde a la fila de la restricción aritmética, es decir, podemos enumerar las restricciones (filas) de la siguiente manera:
Así que la primera fila tiene 1 en la columna de , la segunda fila tiene 1 en la columna de , y la tercera fila tiene 1 en la columna de . Todo lo demás es cero.
Esto resulta en lo siguiente para la matriz :
Este diagrama ilustra la transformación.
Construyendo la matriz
Queda como ejercicio para el lector determinar que la matriz es
usando las etiquetas de columnas consistentes con las matrices anteriores.
A modo de recordatorio, se deriva del resultado de la multiplicación
Y las etiquetas de las columnas son las siguientes
Verificando nuestro trabajo para
import numpy as np
# enter the A B and C from above
L = np.matrix([[0,0,1,0,0,0,0,0],
[0,0,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0]])
R = np.matrix([[0,0,0,1,0,0,0,0],
[0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1]])
O = np.matrix([[0,0,0,0,0,0,1,0],
[0,0,0,0,0,0,0,1],
[0,1,0,0,0,0,0,0]])
# random values for x, y, z, and u
import random
x = random.randint(1,1000)
y = random.randint(1,1000)
z = random.randint(1,1000)
u = random.randint(1,1000)
# compute the algebraic circuit
r = x * y * z * u
v1 = x*y
v2 = z*u
# create the witness vector
a = np.array([1, r, x, y, z, u, v1, v2])
# element-wise multiplication, not matrix multiplication
result = np.matmul(O, a) == np.multiply(np.matmul(L, a), np.matmul(R, a))
assert result.all(), "system contains an inequality"
Ejemplo 3: Suma con una constante
¿Qué pasa si queremos construir un rank one constraint system para lo siguiente?
Aquí es donde resulta útil esa columna del 1.
La suma es gratis
Probablemente hayas escuchado la afirmación “la suma es gratis” (addition is free) en el contexto de ZK-SNARKs. Lo que eso significa es que no tenemos que crear una restricción adicional cuando tenemos una operación de suma.
Podríamos escribir la fórmula anterior como
pero eso haría que nuestro R1CS fuera más grande de lo necesario.
En su lugar, podemos escribirlo como
Entonces la variable y la constante se “combinan” automáticamente cuando multiplicamos por con nuestro vector witness.
Nuestro vector witness tiene la forma [1, z, x, y], por lo que nuestras matrices , y son las siguientes:
Siempre que haya constantes aditivas, simplemente las colocamos en la columna del , la cual por convención es la primera columna.
Nuevamente, hagamos algunas pruebas unitarias sobre nuestras matemáticas:
import numpy as np
import random
# Define the matrices
L = np.matrix([[0,0,1,0]])
R = np.matrix([[0,0,0,1]])
O = np.matrix([[-2,1,0,0]])
# pick random values to test the equation
x = random.randint(1,1000)
y = random.randint(1,1000)
z = x * y + 2 # witness vector
a = np.array([1, z, x, y])
# check the equality
result = O.dot(a) == np.multiply(np.matmul(L, a), R.dot(a))
assert result.all(), "result contains an inequality"
Ejemplo 4: Multiplicación con una constante
En todos los ejemplos anteriores, nunca multiplicamos variables por constantes. Es por eso que las entradas en el R1CS siempre eran 1. Como habrás adivinado del ejemplo anterior, la entrada en las matrices tiene el mismo valor que la constante por la cual se multiplica la variable, como mostrará el siguiente ejemplo.
Calculemos la solución para
Ten en cuenta que cuando decimos “una multiplicación por restricción” nos referimos a la multiplicación de dos variables. La multiplicación con una constante no es una multiplicación “verdadera” porque en realidad es la suma repetida de la misma variable.
La siguiente solución es válida, pero crea filas innecesarias:
La solución más óptima es la siguiente:
Usando la solución más óptima, nuestro vector witness tendrá la forma [1, out, x, y].
Las matrices se definirán de la siguiente manera:
Al multiplicar simbólicamente lo anterior por [1, z, x, y] en la forma r1cs, recuperamos nuestra ecuación original:
así que sabemos que configuramos , y correctamente.
Aquí tenemos una fila (restricciones) y una multiplicación “verdadera”. Como regla general:
El número de restricciones en un Rank One Constraint System debería ser igual al número de multiplicaciones no constantes.
Ejemplo 5: Una restricción grande
Hagamos algo menos trivial que incorpore todo lo aprendido anteriormente
Supongamos que tenemos la siguiente restricción:
La dividiremos de la siguiente manera:
Nota cómo todos los términos de suma se han movido a la izquierda (esto es lo que hicimos en el ejemplo de suma, pero aquí es más evidente).
Dejar el lado derecho como en la tercera fila es arbitrario. Podríamos dividir ambos lados por 5 y hacer que la restricción final sea
Sin embargo, esto no cambia el witness, por lo que ambas son válidas. Dado que todo se realiza en un campo finito, esta operación es multiplicar el lado izquierdo y el lado derecho por el inverso multiplicativo de 5.
Nuestro vector witness tendrá la forma
Y nuestras matrices tendrán tres filas, ya que tenemos tres restricciones:
Hemos marcado la salida en rojo, el lado izquierdo en verde, y el lado derecho en violeta. Esto produce las siguientes matrices:
con las etiquetas de columna
Revisemos nuestro trabajo como de costumbre.
import numpy as np
import random
# Define the matrices
L = np.array([[0,0,3,0,0,0],
[0,0,0,0,1,0],
[0,0,5,0,0,0]])
R = np.array([[0,0,1,0,0,0],
[0,0,0,1,0,0],
[0,0,0,1,0,0]])
O = np.array([[0,0,0,0,1,0],
[0,0,0,0,0,1],
[-3,1,1,2,0,-1]])
# pick random values for x and y
x = random.randint(1,1000)
y = random.randint(1,1000)
# this is our orignal formula
out = 3 * x * x * y + 5 * x * y - x - 2 * y + 3 # the witness vector with the intermediate variables inside
v1 = 3*x*x
v2 = v1 * y
w = np.array([1, out, x, y, v1, v2])
result = O.dot(w) == np.multiply(L.dot(w),R.dot(w))
assert result.all(), "result contains an inequality"
Los Rank 1 Constraint Systems no requieren comenzar con un solo polinomio
Para mantener las cosas simples, hemos estado usando ejemplos de la forma pero la mayoría de las restricciones aritméticas realistas van a ser un conjunto de restricciones aritméticas, no solo una.
Por ejemplo, supongamos que estamos probando que un arreglo es binario y es menor que 16. El conjunto de restricciones será
Para convertir esto en un rank one constraint system, notamos que la fila final no tiene ninguna multiplicación, por lo que podemos sustituir en la primera restricción:
Asumiendo que nuestro vector witness es , podemos crear el R1CS de la siguiente manera:
Hacer la sustitución no es estrictamente necesario, pero ahorra una fila en el R1CS. En una sección posterior, mostraremos un R1CS válido donde no hacemos la sustitución.
Todo se hace módulo un primo en r1cs
En los ejemplos anteriores, usamos aritmética tradicional en aras de la simplicidad, pero las implementaciones del mundo real usan aritmética modular en su lugar.
La razón es simple: codificar números como 2/3 conduce a números de punto flotante de mal comportamiento que son computacionalmente intensivos y propensos a errores.
Si hacemos todas nuestras matemáticas módulo un número primo, digamos 23, entonces codificar es sencillo. Es lo mismo que , y multiplicar por dos y elevar a la potencia de negativo 1 son procesos directos en aritmética modular.
Implementación en Circom.
En Circom, un lenguaje para construir Rank 1 Constraint Systems, el campo finito usa el número primo (esto es igual al orden de la curva BN128 que discutimos en Elliptic Curves over Finite Fields).
Esto significa que en esa representación es
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617
# 1 - 2 = -1
(1 - 2) % p
# 21888242871839275222246405745257275088548364400416034343698204186575808495616
Circom para out = x * y
Si escribimos out = x * y en Circom, se vería como lo siguiente:
pragma circom 2.0.0;
template Multiply2() {
signal input x;
signal input y;
signal output out;
out <== x * y;
}
component main = Multiply2();
Convirtamos esto en un archivo R1CS e imprimamos el archivo R1CS
circom multiply2.circom --r1cs --sym
snarkjs r1cs print multiply2.r1cs
Obtenemos la siguiente salida:

Esto se ve un poco diferente a nuestra solución R1CS, pero en realidad está codificando la misma información.
Aquí están las diferencias en la implementación de Circom:
- Las columnas con valor cero no se imprimen
- Circom escribe como
¿Qué pasa con el 21888242871839275222246405745257275088548364400416034343698204186575808495616 que es realmente -1?
La solución de Circom es
Aunque los unos negativos puedan ser inesperados, con el vector witness [1 out x y], esto es en realidad consistente con la forma . (Veremos en un segundo que Circom efectivamente usó esta asignación de columnas).
Puedes sustituir los valores para , y out y ver que la ecuación se cumple.
Veamos la asignación de variables a columnas de Circom. Recompilemos nuestro circuito con un solver wasm:
circom multiply2.circom --r1cs --wasm --sym
cd multiply2_js/
Creamos el archivo input.json
echo '{"x": "11", "y": "9"}' > input.json
Y calculamos el witness
node generate_witness.js multiply2.wasm input.json witness.wtns
snarkjs wtns export json witness.wtns witness.json
cat witness.json
Obtenemos el siguiente resultado:

Está claro que Circom está usando el mismo diseño de columnas que hemos estado usando: [1, out, x, y], ya que se estableció en e en en nuestro input.json.
Si usamos el witness generado de Circom (reemplazando el número masivo con -1 para mayor legibilidad), entonces vemos que el R1CS de Circom es correcto
tiene un coeficiente de para , tiene un coeficiente de para , y tiene para . En forma modular, esto es idéntico a lo que la terminal generó arriba:

Revisando el resto de nuestro trabajo
A modo de repaso, las fórmulas que exploramos fueron
Acabamos de hacer (1) en la sección anterior, para esta sección ilustraremos el principio de que el número de multiplicaciones no constantes es el número de restricciones.
El circuito para (2) es:
pragma circom 2.0.8;
template Multiply4() {
signal input x;
signal input y;
signal input z;
signal input u;
signal v1;
signal v2;
signal out;
v1 <== x * y;
v2 <== z * u;
out <== v1 * v2;
}
component main = Multiply4();
Con todo lo que hemos discutido hasta ahora, la salida de Circom y las anotaciones deberían explicarse por sí solas.

Con eso en mente, nuestras otras fórmulas deberían tener las siguientes restricciones:
Queda como ejercicio para el lector escribir los circuitos de Circom y verificar lo anterior.
No necesitas un witness para calcular el R1CS
Nota que en el código de Circom nunca proporcionamos el witness antes de calcular el R1CS. Proporcionamos el witness antes para hacer el ejemplo menos abstracto y para facilitar la revisión de nuestro trabajo, pero no es necesario. ¡Esto es importante, porque si un verificador necesitara un witness para construir un R1CS, entonces el probador tendría que revelar la solución oculta!
Cuando decimos “witness” nos referimos a un vector con valores poblados. El verificador conoce la “estructura” del witness, es decir, las asignaciones de variables a columnas, pero no conoce los valores.
Un R1CS es válido incluso si no está optimizado
Una transformación válida de un polinomio a un R1CS no es única. Puedes codificar el mismo problema con más restricciones, lo cual es menos eficiente. Aquí hay un ejemplo.
En algunos tutoriales de R1CS, las restricciones para una fórmula como
se transforman en
Como hemos notado, esto no es eficiente. Sin embargo, creas un R1CS válido para esto usando la metodología de este artículo. Simplemente agregamos una multiplicación ficticia de esta manera:
Nuestro vector witness tiene la forma y , , y se definen de la siguiente manera:
La segunda fila de lleva a cabo la suma, y la multiplicación por uno se logra utilizando el primer elemento de la segunda fila de .
Esto es perfectamente válido, pero la solución tiene una fila y una columna más de lo que necesita.
¿Qué pasa si no hay multiplicaciones?
¿Qué pasa si queremos codificar el siguiente circuito?
Esto es bastante inútil en la práctica, pero en aras de la completitud, puede resolverse con una multiplicación ficticia por uno.
Con nuestro diseño típico del vector witness de , tenemos las siguientes matrices:
Los Rank One Constraint Systems son por conveniencia
El documento original para Groth16 no tiene ninguna referencia al término Rank One Constraint System. Un R1CS es práctico desde la perspectiva de la implementación, pero desde una perspectiva matemática pura, simplemente etiqueta y agrupa explícitamente los coeficientes de las diferentes variables. Así que cuando lees trabajos académicos sobre el tema, generalmente falta porque es un detalle de implementación de un concepto más abstracto.
Recursos útiles
-
Esta herramienta web calcula R1CS para un conjunto de restricciones (pero solo funciona con una variable de entrada y una de salida).
Aprende más con RareSkills
Esta publicación de blog se extrajo de los materiales de aprendizaje en nuestro curso de zero knowledge.