Раздел 30 · Web3 на Rust

ZK вглубь: арифметизация, R1CS, SNARK против STARK

lead~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

ZK вглубь: арифметизация, R1CS, SNARK против STARK

В прошлом уроке мы приняли на веру, что бывает короткое доказательство, которое подтверждает целый батч транзакций, и проверить его дешевле, чем повторить вычисления. Пора разобрать этот фокус. Мы пройдём весь путь: программа, схема, система уравнений, полиномы, доказательство. Систему ограничений напишем на Rust руками и убедимся, что соврать в ней нельзя.

Что вообще доказывают

Начнём с точной постановки, потому что слово «доказательство» тут значит не то, что в математике на бумаге.

Есть утверждение: например, «я знаю такой x, что x³ + x + 5 = 35». Есть свидетель (witness): само значение x, которое доказывающий держит при себе. И есть публичная часть: то, что видно проверяющему, здесь это число 35 и сама формула.

От системы доказательства требуют три свойства:

  • Полнота. Если утверждение верно и доказывающий честен, проверяющий примет доказательство.
  • Корректность. Если утверждение ложно, обманщик почти наверняка не сможет построить принимаемое доказательство. Слово «почти» здесь честное: вероятность обмана не ноль, а величина вроде одной двухсотой степени двойки, что на практике невозможно.
  • Нулевое разглашение. Проверяющий узнаёт только факт истинности утверждения и ничего сверх: сам x остаётся у доказывающего.

Первые два свойства ценны сами по себе, и в rollup-ах работают в основном они: краткость и быстрая проверка. Приватность бонусом, и в rollup-е она обычно не используется вовсе. Отсюда занудное, но точное замечание: то, что все называют ZK-rollup, правильнее звать rollup на доказательствах корректности, потому что нулевое разглашение там чаще всего никому не нужно.

Простейший пример такой системы мы уже собирали: протокол Шнорра и приём Фиата-Шамира доказывают знание приватного ключа, не показывая его. Разница между тем протоколом и сегодняшним разговором в общности. Шнорр доказывает одно конкретное утверждение про дискретный логарифм. Сегодня нужно доказывать произвольное вычисление: любую программу, а не одну формулу.

Шаг 1: программа превращается в схему

Криптография умеет работать с арифметикой в конечном поле: сложение и умножение по модулю большого простого числа. Значит, программу надо переписать так, чтобы от неё осталась только арифметика. Этот перевод называется арифметизацией.

Возьмём классический пример: доказать знание x, для которого x³ + x + 5 = 35. Сначала сплющиваем выражение так, чтобы в каждой строке было не больше одной операции:

sym1 = x * x
y    = sym1 * x
sym2 = y + x
out  = sym2 + 5

Четыре строки, четыре переменные плюс вход. Ничего не поменялось по смыслу, но теперь каждый шаг элементарен. Именно это и делают компиляторы ZK-языков (Circom, Cairo, Noir), только с миллионами строк и без участия человека.

Уже здесь видна главная особенность мышления в схемах. Обычная программа исполняется: часть веток не выполняется вовсе. Схема же существует целиком и всегда: если в программе был if, в схеме будут обе ветки плюс переменная-переключатель, которая обнуляет ненужную. Схема не умеет «не выполнять» код, она умеет только зануле́ние. Отсюда и цены: цикл с переменным числом итераций разворачивают на максимум, а обращение к памяти превращается в отдельную конструкцию с доказательством корректности доступа.

Шаг 2: схема превращается в систему ограничений

Теперь запишем эти четыре строки в единообразной форме, с которой уже умеет работать криптография. Форма называется R1CS.

Собираем вектор свидетеля из всех значений программы разом:

w = [1, out, x, sym1, y, sym2]

Первая единица нужна, чтобы выражать константы, вторым идёт публичный выход, дальше секретный вход и промежуточные значения. Каждое ограничение это тройка векторов коэффициентов A, B, C, а выполняется оно, когда (A·w) * (B·w) = (C·w), где точка это скалярное произведение.

Вот вся система на Rust, вместе с проверкой свидетеля и двумя попытками соврать:

/// Учебное поле: все операции по модулю простого p. Настоящие схемы берут
/// p длиной 254 бита, здесь маленькое, чтобы арифметика читалась глазами.
const P: u64 = 97;

fn add(a: u64, b: u64) -> u64 { (a + b) % P }
fn mul(a: u64, b: u64) -> u64 { (a * b) % P }

/// Одно ограничение R1CS: три вектора коэффициентов над вектором свидетеля.
struct Constraint {
    a: Vec<u64>,
    b: Vec<u64>,
    c: Vec<u64>,
}

/// Скалярное произведение строки коэффициентов на вектор свидетеля.
fn dot(row: &[u64], w: &[u64]) -> u64 {
    row.iter().zip(w).fold(0, |acc, (coeff, value)| add(acc, mul(*coeff, *value)))
}

impl Constraint {
    /// Ограничение выполнено, если (A·w) * (B·w) == (C·w).
    fn holds(&self, w: &[u64]) -> bool {
        mul(dot(&self.a, w), dot(&self.b, w)) == dot(&self.c, w)
    }
}

/// Схема для x^3 + x + 5 = out.
/// Свидетель: [1, out, x, sym1, y, sym2].
fn circuit() -> Vec<Constraint> {
    vec![
        // x * x = sym1
        Constraint { a: vec![0,0,1,0,0,0], b: vec![0,0,1,0,0,0], c: vec![0,0,0,1,0,0] },
        // sym1 * x = y
        Constraint { a: vec![0,0,0,1,0,0], b: vec![0,0,1,0,0,0], c: vec![0,0,0,0,1,0] },
        // (y + x) * 1 = sym2
        Constraint { a: vec![0,0,1,0,1,0], b: vec![1,0,0,0,0,0], c: vec![0,0,0,0,0,1] },
        // (sym2 + 5) * 1 = out
        Constraint { a: vec![5,0,0,0,0,1], b: vec![1,0,0,0,0,0], c: vec![0,1,0,0,0,0] },
    ]
}

/// Честный свидетель: прогоняем программу и записываем КАЖДЫЙ шаг.
fn witness(x: u64) -> Vec<u64> {
    let sym1 = mul(x, x);
    let y = mul(sym1, x);
    let sym2 = add(y, x);
    let out = add(sym2, 5);
    vec![1, out, x, sym1, y, sym2]
}

fn satisfied(w: &[u64]) -> bool {
    circuit().iter().all(|constraint| constraint.holds(w))
}

fn main() {
    let honest = witness(3);
    println!("свидетель x=3: {honest:?}");
    println!("система выполнена: {}", satisfied(&honest));

    // Попытка соврать: заявляем нужный выход, не меняя промежуточные значения.
    let mut forged = witness(4);
    forged[1] = 35;
    println!("подделка выхода: система выполнена: {}", satisfied(&forged));

    // Попытка соврать аккуратнее: подгоняем и предпоследний шаг.
    let mut forged2 = witness(4);
    forged2[1] = 35;
    forged2[5] = 30; // sym2 = out - 5
    println!("подделка двух шагов: система выполнена: {}", satisfied(&forged2));
}

Вывод программы:

свидетель x=3: [1, 35, 3, 9, 27, 30]
система выполнена: true
подделка выхода: система выполнена: false
подделка двух шагов: система выполнена: false

Здесь и лежит суть корректности. Система связывает каждый шаг вычисления через общий вектор свидетеля. Подкрутить выход не выйдет: сломается последнее ограничение. Подкрутить выход вместе с предыдущим шагом тоже не выйдет: сломается предыдущее. Чтобы система сошлась, придётся подобрать согласованный набор значений всех переменных, а это ровно и означает «честно провести вычисление».

Поиграй с этим ниже: двигай секрет, ломай промежуточное значение и смотри, какая строка перестаёт сходиться.

Обрати внимание на размер: три сложения и одно возведение в куб дали четыре ограничения. У настоящего zkEVM их десятки миллионов, и вся инженерия там про то, как ужать это число.

Шаг 3: зачем полиномы

Проверка, которую мы написали, требует пройти по всем ограничениям. Для миллионов ограничений это не годится: проверяющий должен работать за время, почти не зависящее от размера вычисления. Спасает переход к полиномам.

Идея в двух шагах. Сначала все строки матриц A, B, C интерполируют в полиномы: значение полинома в точке i это коэффициент из i-го ограничения. Мы этот приём уже видели в схеме Шамира, где через точки восстанавливали секрет.

Дальше вся система из миллионов проверок превращается в одно полиномиальное тождество. Грубо говоря, A(z)·B(z) - C(z) обязано делиться нацело на известный полином Z(z), у которого корни ровно в точках-номерах ограничений. Выполнено тождество, значит выполнены все ограничения разом.

Почему это выгодно? Потому что работает лемма Шварца-Циппеля: два разных полинома невысокой степени совпадают лишь в исчезающе малой доле точек поля. Значит, проверяющему достаточно спросить значение в одной случайной точке. Если доказывающий не знал точку заранее, обмануть он может лишь с вероятностью порядка «степень полинома, делённая на размер поля», а поле у нас в районе 2²⁵⁴.

Отсюда и берётся краткость: проверка миллионов ограничений сводится к проверке нескольких значений в одной точке. Остаётся техническая деталь: доказывающий не должен узнать точку заранее и не должен иметь возможности подменить свои полиномы задним числом. Для этого нужны обязательства к полиному: короткая метка, которая фиксирует полином целиком, позволяет раскрыть его значение в запрошенной точке и не даёт соврать. Обязательства мы разбирали в общем виде там же, в уроке про ZK-основы.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.