A veces también llamados mapeos bilineales, los emparejamientos bilineales nos permiten tomar tres números, , y , donde , encriptarlos para que se conviertan en , , , donde es una función de encriptación, y luego enviar los dos valores encriptados a un verificador que puede verificar que pero no conocer los valores originales. Podemos usar emparejamientos bilineales para probar que un tercer número es el producto de los dos primeros sin conocer los dos primeros números originales.
Explicaremos los emparejamientos bilineales a alto nivel y proporcionaremos algunos ejemplos en Python.
Requisitos previos
- El lector debe saber qué son la suma de puntos y la multiplicación escalar en el contexto de las curvas elípticas.
- El lector también debe conocer el problema del logaritmo discreto en este contexto: un escalar multiplicado por un punto dará como resultado otro punto, y en general es inviable calcular el escalar dado el punto de la curva elíptica.
- El lector debe saber qué son un campo finito y un grupo cíclico, y qué es un generador en el contexto de las curvas elípticas. Nos referiremos a los generadores con la variable G.
- El lector debe conocer sobre los precompilados de Ethereum.
Usaremos letras mayúsculas para denotar puntos de curva elíptica (EC, por sus siglas en inglés) y letras minúsculas para denotar elementos de campos finitos (“escalares”). Cuando decimos elemento, podría ser un número entero en un campo finito o podría ser un punto en una curva elíptica. El contexto lo aclarará.
Es posible leer este tutorial sin comprender completamente todo lo anterior, pero será más difícil desarrollar una buena intuición sobre este tema.
Cómo funcionan los emparejamientos bilineales
Cuando un escalar se multiplica por un punto en una curva elíptica, se produce otro punto de la curva elíptica. Es decir, donde es un escalar y es el generador. Dados y no podemos determinar .
Supongamos que . Lo que intentamos hacer es tomar
y convencer a un verificador de que los logaritmos discretos de y se multiplican para producir el logaritmo discreto de .
Si , y , , y , entonces queremos una función tal que
y no sea igual a cuando . Esto debería cumplirse para todas las combinaciones posibles de , y en el grupo.
Sin embargo, normalmente no es así como expresamos cuando usamos emparejamientos bilineales. Por razones que discutiremos más adelante, generalmente se calcula como
es el punto generador, y puede considerarse como . En este contexto. Por ejemplo, significa que hicimos veces. simplemente significa que tomamos y no agregamos nada. Así que, en cierto sentido, esto es lo mismo que decir .
Entonces, nuestro emparejamiento bilineal es una función en la que si introduces dos puntos de la curva elíptica obtienes una salida que corresponde al producto de los logaritmos discretos de esos dos puntos.
Notación
Un emparejamiento bilineal se escribe generalmente como . Aquí, no tiene nada que ver con el logaritmo natural, y y son puntos de la curva elíptica.
Generalización, comprobando si dos productos son iguales
Supongamos que alguien nos da cuatro puntos de curvas elípticas , , y y afirma que los logaritmos discretos de y tienen el mismo producto que y , es decir, . Usando un emparejamiento bilineal, podemos comprobar si esto es cierto sin conocer , , o . Simplemente hacemos:
Qué significa “bilineal”
Bilineal significa que si una función toma dos argumentos, y uno de ellos se mantiene constante, y el otro varía, entonces la salida varía linealmente con el argumento no constante.
Si es bilineal, y es constante, entonces varía linealmente con y varía linealmente con .
A partir de esto podemos inferir que un emparejamiento bilineal de curva elíptica tiene la siguiente propiedad:
¿Qué devuelve ?
Para ser sinceros, el resultado es tan aterrador matemáticamente que sería contraproducente intentar explicarlo realmente. Es por esto que gran parte del libro anterior dedicó mucho tiempo a explicar los Grupos, porque es exponencialmente más fácil entender qué es un Grupo que entender qué devuelve .
El resultado de un emparejamiento bilineal es un elemento de grupo, específicamente un elemento de un grupo cíclico finito.
Lo mejor es tratar a como una caja negra de manera similar a cómo la mayoría de los programadores tratan las funciones hash como cajas negras.
Sin embargo, a pesar de ser una caja negra, sabemos mucho sobre las propiedades del resultado, al que llamamos :
- es un grupo cíclico, por lo que tiene un operador binario cerrado.
- El operador binario de es asociativo.
- tiene un elemento identidad.
- Cada elemento en tiene un inverso.
- Debido a que el grupo es cíclico, tiene un generador.
- Debido a que el grupo es cíclico y finito, entonces los grupos cíclicos finitos son homomórficos a . Es decir, tenemos alguna forma de mapear homomórficamente elementos en un campo finito a elementos en .
Debido a que el grupo es cíclico, tenemos una noción de , , , y así sucesivamente. El operador binario de es aproximadamente lo que llamaríamos “multiplicación”, de modo que .
Si realmente quieres saber cómo “se ve” , es un objeto de 12 dimensiones. Sin embargo, el elemento identidad no tiene un aspecto tan aterrador:
Grupos simétricos y asimétricos
La notación anterior implica que estamos usando el mismo generador y grupo de curva elíptica en todas partes cuando decimos
Sin embargo, en la práctica resulta más fácil crear emparejamientos bilineales cuando un grupo diferente (pero del mismo orden) es distinto para ambos argumentos.
Específicamente, decimos
Ninguno de los grupos utilizados es el mismo.
Sin embargo, la propiedad que nos interesa se sigue manteniendo.
En la ecuación anterior, el grupo no se muestra explícitamente, pero es el codominio (espacio de salida) de .
Uno podría pensar que y son diferentes ecuaciones de curvas elípticas con diferentes parámetros (pero el mismo número de puntos) y eso sería válido porque son grupos diferentes.
En un emparejamiento simétrico, se utiliza el mismo Grupo de curva elíptica para ambos argumentos de la función de emparejamiento bilineal. Esto significa que el generador y el grupo de curva elíptica utilizados en ambos argumentos son los mismos. En este caso, la función de emparejamiento suele denotarse como:
En un emparejamiento asimétrico, los argumentos utilizan grupos diferentes. Por ejemplo, el primer argumento puede utilizar un generador y un grupo de curva elíptica diferentes a los del segundo argumento. La función de emparejamiento aún puede satisfacer las propiedades deseadas
En la práctica usamos grupos asimétricos, y la diferencia entre los grupos que usamos se explica en la siguiente sección.
es el mismo grupo del que hablamos en capítulos anteriores, y en el contexto de Ethereum, es el mismo G1 que importamos de la biblioteca:
from py_ecc.bn128 import G1
También podemos importar el G2 de la misma biblioteca como:
from py_ecc.bn128 import G1, G2
Pero, ¿qué es ?
Extensiones de campos y el punto G2 en Python
Los emparejamientos bilineales son bastante agnósticos al tipo de grupos que se elija, pero el de Ethereum usa curvas elípticas con extensiones de campo. Si quieres poder leer código Solidity que use ZK-SNARKS, al menos necesitarás una idea aproximada de qué son.
Generalmente pensamos en los puntos EC como dos puntos e . Con las extensiones de campo, la y la mismas se convierten en objetos bidimensionales, pares . Esto es análogo a cómo los números complejos “extienden” los números reales y los convierten en algo de 2 dimensiones (un componente real y un componente imaginario).
Una extensión de campo es un concepto muy abstracto y, francamente, la relación entre un campo y su extensión no importa desde un concepto puramente funcional.
Simplemente piénsalo de esta manera:

Una curva elíptica en es una curva elíptica donde tanto el elemento como el son objetos bidimensionales.
El punto G2 en Python
Suficiente teoría, codifiquemos esto y veamos un punto . Instala la biblioteca py_ecc de la siguiente manera.
python -m pip install py_ecc
Ahora importemos las funciones que necesitamos de esta
from py_ecc.bn128 import G1, G2, pairing, add, multiply, eq
print(G1)
# (1, 2)
print(G2)
#((10857046999023057135944570762232829481370756359578518086990519993285655852781, 11559732032986387107991004021392285783925812861821192530917403151452391805634), (8495653923123431417604973247489272438418190587263600148770280649306958101930, 4082367875863433681332203403145435568316851327593401208105741076214120093531))
Si miras de cerca el G2, verás que G2 es un par de tuplas. La primera tupla es el punto bidimensional y la segunda tupla es el punto bidimensional .
G1 y G2 son los puntos generadores de sus respectivos grupos.
Tanto G1 como G2 tienen el mismo orden (número de puntos en la curva):
from py_ecc.bn128 import G1, G2, eq, curve_order, multiply, eq, curve_order
x = 10 # chosen randomly
assert eq(multiply(G2, x + curve_order), multiply(G2, x))
assert eq(multiply(G1, x + curve_order), multiply(G1, x))
Aunque los puntos G2 puedan parecer un poco extraños, su comportamiento es el mismo que el de otros grupos cíclicos, especialmente el grupo G1 con el que estamos familiarizados. Esto significa que podemos construir otros puntos con la multiplicación escalar (que en realidad es una suma repetida) como se espera
print(eq(add(G1, G1), multiply(G1, 2)))
# True
print(eq(add(G2, G2), multiply(G2, 2)))
# True
Debería ser obvio que solo se pueden sumar elementos del mismo grupo.
add(G1, G2) # TypeError
Por cierto, esta biblioteca sobrecarga algunos operadores aritméticos (puedes hacer eso en Python), lo que significa que puedes hacer lo siguiente:
print(G1 + G1 + G1 == G1*3)
# True
# The above is the same as this:
eq(add(add(G1, G1), G1), multiply(G1, 3))
# True
Emparejamientos bilineales en Python
Al comienzo de este artículo, dijimos que los emparejamientos bilineales se pueden usar para comprobar si los logaritmos discretos de y se multiplican para producir el logaritmo discreto de , es decir, .
Así es como podemos hacer eso en Python:
from py_ecc.bn128 import G1, G2, pairing, multiply, eq
P = multiply(G1, 3)
Q = multiply(G2, 8)
R = multiply(G1, 24)
assert eq(pairing(Q, P), pairing(G2, R))
Algo bastante molesto es que la biblioteca requiere que pases el punto G2 como primer argumento a pairing.
Igualdad de productos
También al comienzo de este artículo dijimos que un emparejamiento puede comprobar:
Así es como podemos hacer eso en Python:
from py_ecc.bn128 import G1, G2, pairing, multiply, eq
P_1 = multiply(G1, 3)
P_2 = multiply(G2, 8)
Q_1 = multiply(G1, 6)
Q_2 = multiply(G2, 4)
assert eq(pairing(P_2, P_1), pairing(Q_2, Q_1))
El operador binario de
Los elementos en se combinan usando la “multiplicación”, pero ten en cuenta que esto es en realidad una sobrecarga sintáctica en Python:
from py_ecc.bn128 import G1, G2, pairing, multiply, eq
# 2 * 3 = 6
P_1 = multiply(G1, 2)
P_2 = multiply(G2, 3)
# 4 * 5 = 20
Q_1 = multiply(G1, 4)
Q_2 = multiply(G2, 5)
# 10 * 12 = 120 (6 * 20 = 120 also)
R_1 = multiply(G1, 10)
R_2 = multiply(G2, 12)
assert eq(pairing(P_2, P_1) * pairing(Q_2, Q_1), pairing(R_2, R_1))
# Fails!
¡Pero la aserción falla!
Los elementos en se comportan como “potencias” de una base.
Recuerda del álgebra que
Supongamos que generamos un elemento en como . Podríamos pensar en el elemento como , pero sería mucho más útil pensar en él como . No hay necesidad de saber qué es en este contexto, simplemente que existe.
Por lo tanto, para que nuestro código anterior funcione, cambia y para que se multipliquen hasta dar 26.
Nuestro código está calculando efectivamente:
from py_ecc.bn128 import G1, G2, pairing, multiply, eq
# 2 * 3 = 6
P_1 = multiply(G1, 2)
P_2 = multiply(G2, 3)
# 4 * 5 = 20
Q_1 = multiply(G1, 4)
Q_2 = multiply(G2, 5)
# 13 * 2 = 26
R_1 = multiply(G1, 13)
R_2 = multiply(G2, 2)
# b ^ {2 * 3} * b ^ {4 * 5} = b ^ {13 * 2}
# b ^ 6 * b ^ 20 = b ^ 26
assert eq(pairing(P_2, P_1) * pairing(Q_2, Q_1), pairing(R_2, R_1))
Emparejamientos bilineales en Ethereum
Especificación del EIP 197
La biblioteca py_ecc es en realidad mantenida por la Fundación Ethereum, y es lo que impulsa el precompilado en la dirección 0x8 en la implementación de PyEVM.
El precompilado de Ethereum definido en EIP-197 funciona sobre puntos en G1 y G2, y de forma implícita funciona sobre puntos en .
La especificación de este precompilado parecerá un poco extraña al principio. Toma una lista de puntos G1 y G2 dispuestos de la siguiente manera:
A₁B₁A₂B₂...AₙBₙ : Aᵢ ∈ G1, Bᵢ ∈ G2
Estos se crearon originalmente como
A₁ = a₁G1
B₁ = b₁G2
A₂ = a₂G1
B₂ = b₂G2
...
Aₙ = aₙG1
Bₙ = bₙG2
El precompilado devuelve 1 si lo siguiente es verdadero
a₁b₁ + a₂b₂ + ... + aₙbₙ = 0
y cero en caso contrario.
¡Esto podría ser un poco confuso al principio! Esto parece implicar que el precompilado está calculando el logaritmo discreto de cada uno de los puntos, lo cual se acepta que es inviable en general. Además, ¿por qué no se comporta como el emparejamiento de los ejemplos anteriores de Python? Los ejemplos anteriores devolvían un elemento en , pero este precompilado devuelve un booleano.
Justificación de la decisión de diseño del EIP 197
El primer problema es que los elementos en son grandes, específicamente, son objetos de 12 dimensiones.
Esto ocupará mucho espacio en la memoria, lo que provocará mayores costes de gas. Además, debido a cómo funcionan la mayoría de los algoritmos de verificación de ZK (esto está fuera del alcance de este artículo), generalmente no comprobamos el valor del resultado de un emparejamiento, sino solo que sea igual a otros emparejamientos. Específicamente, el paso final en Groth16 (el algoritmo de conocimiento cero utilizado por tornado cash) se ve de la siguiente manera
Donde cada variable es un punto de la curva elíptica de o según su notación de subíndice (habríamos usado letras griegas mayúsculas para mantener la consistencia con nuestra notación, pero se parecen demasiado al alfabeto latino).
El significado de estas variables no es importante en esta etapa. El hecho de que se pueda escribir como la suma de “productos” (emparejamiento de curvas elípticas) es lo que importa. Específicamente, podemos escribirlo como
¡Y ahora coincide perfectamente con la especificación del precompilado!
No es solo Groth16, la mayoría de los algoritmos de zk tienen fórmulas de verificación que se ven así, razón por la cual el precompilado fue diseñado para trabajar con sumas de emparejamientos en lugar de devolver el valor de un solo emparejamiento.
Si echamos un vistazo al código de verificación de Tornado Cash, podemos ver que está implementando exactamente esto (incluso las letras griegas coinciden, pero no te preocupes si aún no lo entiendes). El simplemente significa que es un punto de , el significa un punto de , etc.

Dentro de la función de emparejamiento es donde se hace la llamada a address(8) para completar el cálculo del emparejamiento y determinar si la prueba es válida o no.
A veces, al grupo se le denomina en el contexto de EIP 197.
Suma de logaritmos discretos
La idea clave aquí es que si
Entonces también debe ser cierto que
en el grupo .
El precompilado no está realmente calculando el logaritmo discreto, simplemente está comprobando si la suma de los emparejamientos es cero.
La suma de los emparejamientos es cero si y solo si la suma de los productos de los logaritmos discretos es cero.
Ejemplo de principio a fin en Solidity de emparejamientos bilineales
Tomemos estas entradas de a, b, c y d.
a = 4
b = 3
c = 6
d = 2
-ab + cd = 0
Poniéndolo en la fórmula podemos obtener
En Python esto equivaldría a
from py_ecc.bn128 import neg, multiply, G1, G2
a = 4
b = 3
c = 6
d = 2
# negate G1 * a to make the equation sum up to 0
print(neg(multiply(G1, a)))
#(3010198690406615200373504922352659861758983907867017329644089018310584441462, 17861058253836152797273815394432013122766662423622084931972383889279925210507)
print(multiply(G2, b))
# ((2725019753478801796453339367788033689375851816420509565303521482350756874229, 7273165102799931111715871471550377909735733521218303035754523677688038059653), (2512659008974376214222774206987427162027254181373325676825515531566330959255, 957874124722006818841961785324909313781880061366718538693995380805373202866))
print(multiply(G1, c))
# (4503322228978077916651710446042370109107355802721800704639343137502100212473, 6132642251294427119375180147349983541569387941788025780665104001559216576968)
print(multiply(G2, d))
# ((18029695676650738226693292988307914797657423701064905010927197838374790804409, 14583779054894525174450323658765874724019480979794335525732096752006891875705), (2140229616977736810657479771656733941598412651537078903776637920509952744750, 11474861747383700316476719153975578001603231366361248090558603872215261634898))
Aquí está el resultado en un formato estructurado
aG1_x = 3010198690406615200373504922352659861758983907867017329644089018310584441462,
aG1_y = 17861058253836152797273815394432013122766662423622084931972383889279925210507,
bG2_x1 = 2725019753478801796453339367788033689375851816420509565303521482350756874229,
bG2_x2 = 7273165102799931111715871471550377909735733521218303035754523677688038059653,
bG2_y1 = 2512659008974376214222774206987427162027254181373325676825515531566330959255,
bG2_y2 = 957874124722006818841961785324909313781880061366718538693995380805373202866,
cG1_x = 4503322228978077916651710446042370109107355802721800704639343137502100212473,
cG1_y = 6132642251294427119375180147349983541569387941788025780665104001559216576968,
dG2_x1 = 18029695676650738226693292988307914797657423701064905010927197838374790804409,
dG2_x2 = 14583779054894525174450323658765874724019480979794335525732096752006891875705,
dG2_y1 = 2140229616977736810657479771656733941598412651537078903776637920509952744750,
dG2_y2 = 11474861747383700316476719153975578001603231366361248090558603872215261634898
Ahora que tenemos los valores encriptados en puntos de los grupos y , alguien más o un programa pueden confirmar que calculamos correctamente sin conocer los valores individuales de a, b, c, o d. Aquí hay un contrato en Solidity que usa el precompilado ecPairing para confirmar que calculamos las ecuaciones con valores válidos.
Creamos un archivo Pairings.sol para hacer pruebas unitarias en Foundry (proporcionaremos el archivo de prueba a continuación)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Pairings {
/**
* returns true if == 0,
* returns false if != 0,
* reverts with "Wrong pairing" if invalid pairing
*/
function run(uint256[12] memory input) public view returns (bool) {
assembly {
let success := staticcall(gas(), 0x08, input, 0x0180, input, 0x20)
if success {
return(input, 0x20)
}
}
revert("Wrong pairing");
}
}
Usamos este archivo de prueba de Foundry para desplegar y llamar a nuestro contrato Pairings para confirmar nuestro cálculo de ecPairing. Al siguiente archivo lo llamaremos TestPairings.sol.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Pairings.sol";
contract PairingsTest is Test {
Pairings public pairings;
function setUp() public {
pairings = new Pairings();
}
function testPairings() public view {
uint256 aG1_x = 3010198690406615200373504922352659861758983907867017329644089018310584441462;
uint256 aG1_y = 17861058253836152797273815394432013122766662423622084931972383889279925210507;
uint256 bG2_x1 = 2725019753478801796453339367788033689375851816420509565303521482350756874229;
uint256 bG2_x2 = 7273165102799931111715871471550377909735733521218303035754523677688038059653;
uint256 bG2_y1 = 2512659008974376214222774206987427162027254181373325676825515531566330959255;
uint256 bG2_y2 = 957874124722006818841961785324909313781880061366718538693995380805373202866;
uint256 cG1_x = 4503322228978077916651710446042370109107355802721800704639343137502100212473;
uint256 cG1_y = 6132642251294427119375180147349983541569387941788025780665104001559216576968;
uint256 dG2_x1 = 18029695676650738226693292988307914797657423701064905010927197838374790804409;
uint256 dG2_x2 = 14583779054894525174450323658765874724019480979794335525732096752006891875705;
uint256 dG2_y1 = 2140229616977736810657479771656733941598412651537078903776637920509952744750;
uint256 dG2_y2 = 11474861747383700316476719153975578001603231366361248090558603872215261634898;
uint256[12] memory points = [
aG1_x,
aG1_y,
bG2_x2,
bG2_x1,
bG2_y2,
bG2_y1,
cG1_x,
cG1_y,
dG2_x2,
dG2_x1,
dG2_y2,
dG2_y1
];
bool x = pairings.run(points);
console2.log("result:", x);
}
}
Ten en cuenta que la forma en que se organizan los puntos G2 no es la misma en la que Python dispone los puntos G2.
Esto se ejecuta con éxito e imprime true en la consola. Observa que los puntos han sido etiquetados por el nombre de su variable, el grupo al que pertenecen y si representan una x o una y del punto de la curva elíptica.
Es importante tener en cuenta que el precompilado ecPairing no espera ni requiere un array y que nuestra elección de usar uno con ensamblador en línea (inline-assembly) es simplemente opcional. Uno podría hacer lo mismo con Solidity de esta manera:
function run(bytes calldata input) public view returns (bool) {
// optional, the precompile checks this too and reverts (with no error) if false, this helps narrow down possible errors
if (input.length % 192 != 0) revert("Points must be a multiple of 6");
(bool success, bytes memory data) = address(0x08).staticcall(input);
if (success) return abi.decode(data, (bool));
revert("Wrong pairing");
}
Y actualizar el archivo de prueba de esta manera:
function testPairings() public view {
uint256 aG1_x = 3010198690406615200373504922352659861758983907867017329644089018310584441462;
uint256 aG1_y = 17861058253836152797273815394432013122766662423622084931972383889279925210507;
uint256 bG2_x1 = 2725019753478801796453339367788033689375851816420509565303521482350756874229;
uint256 bG2_x2 = 7273165102799931111715871471550377909735733521218303035754523677688038059653;
uint256 bG2_y1 = 2512659008974376214222774206987427162027254181373325676825515531566330959255;
uint256 bG2_y2 = 957874124722006818841961785324909313781880061366718538693995380805373202866;
uint256 cG1_x = 4503322228978077916651710446042370109107355802721800704639343137502100212473;
uint256 cG1_y = 6132642251294427119375180147349983541569387941788025780665104001559216576968;
uint256 dG2_x1 = 18029695676650738226693292988307914797657423701064905010927197838374790804409;
uint256 dG2_x2 = 14583779054894525174450323658765874724019480979794335525732096752006891875705;
uint256 dG2_y1 = 2140229616977736810657479771656733941598412651537078903776637920509952744750;
uint256 dG2_y2 = 11474861747383700316476719153975578001603231366361248090558603872215261634898;
bytes memory points = abi.encode(
aG1_x,
aG1_y,
bG2_x2,
bG2_x1,
bG2_y2,
bG2_y1,
cG1_x,
cG1_y,
dG2_x2,
dG2_x1,
dG2_y2,
dG2_y1
);
bool x = pairings.run(points);
console2.log("result:", x);
}
Esto se ejecutará con éxito y devolverá true al igual que la implementación inicial porque envía exactamente el mismo calldata al precompilado.
La única diferencia es que en la primera implementación, el archivo de prueba envía un array de puntos al contrato de emparejamiento que usa ensamblador en línea para recortar los primeros 32 bytes (la longitud del array) y envía el resto al precompilado. Y en la segunda implementación, el archivo de prueba envía los puntos codificados con ABI al contrato de emparejamiento, el cual los reenvía tal como están al precompilado.
Aprende más con RareSkills
Este material ha sido extraído de nuestro Curso de Conocimiento Cero. Por favor, consulta el programa para obtener más información.
Realmente quiero entender las matemáticas detrás de los emparejamientos bilineales
Estás advertido, las matemáticas son bastante intensas y entenderlas no te ayudará a implementar realmente pruebas ZK, que es el objetivo de este libro. Probablemente hayas utilizado SHA-256 o Keccak256 de forma productiva sin conocer su funcionamiento interno, y te sugerimos encarecidamente que trates a los emparejamientos de la misma manera en esta etapa de tu viaje. Sin embargo, si nuestras advertencias no te han disuadido, aquí tienes un buen recurso si aún quieres profundizar en el tema: Pairings for Beginners. Aquí hay dragones.
Publicado originalmente el 18 de julio de 2023