Noir 的背后:从代码到约束
2025-03-28 18:00
登链社区
2025-03-28 18:00
订阅此专栏
收藏此文章
登链社区

零知识证明就像是去中心化系统中隐私的魔法咒语。通过它们,你可以证明你知道某件事情而不需要暴露是什么。很酷对吧?但是 ZK 电路开发与普通编程有很大不同。它需要从约束的角度思考,而不仅仅是逻辑。

如果你对零知识证明并不了解,可以尝试先阅读我们的专栏“零知识证明之书”这里有适合开发者的零知识证明教程。订阅链接:https://learnblockchain.cn/column/117/intro(或扫描文末二维码订阅)

这就是 Noir 的作用所在。除了动态约束管理,它引入了许多东西,使 ZK 开发感觉像是编写常规代码一样——黑盒函数、类型化、运行时的 if/else 语句等。

image-20240930222847819.png

尽管 Noir 为你处理了很多复杂的事宜,它在内核中仍然涉及许多对实现其全部潜力至关重要的加密方面。特别是,编译 Noir 电路后会发生什么?它如何从类 Rust 代码转换为构成零知识证明的约束?

在本文中,我们将专注于 Noir 的编译过程。这是将高级代码转换为数学表示的关键步骤。一旦我们理解了这一点,我们将能够看到编译器为我们减轻了多少额外的步骤,并更加欣赏 Noir 的抽象。

第一步:编写 Noir 电路

在深入深邃的内部结构之前,让我们进行一些实际操作。我们将编写三个电路,每个电路在我们的探索中服务于不同的目的,以看看 Noir 能做什么。类 Rust 的语法与无缝的约束管理使你可以将注意力从将业务逻辑分解为约束转移到,仅仅实现它。这就像是正常地编写代码,只是带有 ZK 风味。

HelloWorld 电路

让我们从基本的开始——如果你运行 nargo init 命令,你将得到如下所示的 Noir 项目的样板电路:

fn main(x: Field, y: pub Field) {
    assert(x != y);
}

它检查两个输入 x(私有)和 y(公共)是否不相等。就这样——你的第一个基于约束的逻辑。

Pedersen Hash 电路

现在,让我们增加一些趣味,引入 Pedersen 哈希。哈希就是通过对椭圆曲线上的点进行线性组合来简单计算的。这听起来是 ZK 系统的最佳选择,因为传统哈希函数中广泛使用的昂贵位运算并不受 ZK 系统的欢迎。

use dep::std::hash;

fn main(input: [Field; 2]) -> pub Field {
    hash::pedersen_hash(input)
}

电路看起来小而简单:只需要两个字段元素输入,哈希结果输出。但在幕后,它包含了很多复杂的操作,这些操作位于标准库的 pedersen_hash 函数中。这个电路吸引了我们的兴趣,因为它引入了 ACIR 黑盒操作,如 BLACKBOX::MULTI_SCALAR_MUL,用于哈希,并展示了 Noir 如何在更低级别处理椭圆曲线操作。

内存访问与条件执行 电路

好吧,是时候做一些更高级的东西了。我们的第三个电路稍微复杂一些,因为它使用了内存操作和条件执行。

fn main(index1: u8, index2: u8, values: [Field; 5]) -> pub u32 {
    let val1 = values[index1] as u32;
    let val2 = values[index2] as u32;

    let mut result = 0;

    if val1 > val2 {
        result = val1 + val2;
    } else {
        result = val1 * val2;
    }

    result
}

这个电路涵盖了三个重要点:

  • 动态访问固定大小的数组,触发内存操作码;
  • 条件分支,这将向我们展示用于显式分支的开发者友好的 if 语句是如何在 ACIR 中被转换为约束的。
  • 隐式范围检查,当使用 u8 和 u32 时会被 Noir 的类型系统触发。

第二步:编译到 ACIR

Noir 电路被编译成中间语言 ACIR(抽象电路中间表示)。它将逻辑和算术运算标准化为操作码,并使电路与证明无关。换句话说,ACIR 编译器将 Noir 代码翻译成 ZK 证明系统可以解释的格式——一组结构化的低级算术约束。你可以把它想象成一座桥——一边是 Noir(前端),另一边是任何兼容的证明后端。

要编译我们的电路并分析生成的 ACIR 操作码,请运行:

nargo compile --print-acir

HelloWorld 电路

上面命令输出的第一部分是一般电路信息:

Compiled ACIR for main (unoptimized):
func 0
current witness index : 2
private parameters indices : [0]
public parameters indices : [1]
return value indices : []

它告诉我们有 3 个证明:

  • witness 0 → 私有输入 x;
  • witness 1 → 公共输入 y;
  • witness 2 → 中间值 1 / (x - y)(由下面的 brillig 调用生成)。

之后,我们可以看到 brillig 调用。

BRILLIG CALL func 0:
  inputs: [
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [(1, Witness(0)), (-1, Witness(1))],
        q_c: 0
      }
    )
  ],
  outputs: [Simple(Witness(2))]

在解决电路(生成证明)的过程中, brillig 调用负责纯执行,此阶段不添加约束。它接受输入,执行一些计算,将结果存储在输出证明中。基本上,brillig 调用 = 无约束函数

为什么使用 brillig?它适合那些容易验证但在电路内计算困难的情况。

如你所见,从 inputs 部分:

  • 约束中没有乘法项,意味着传递的是线性表达;
  • 线性组合显示我们将 x - y 传递给 func 0
  • q_c: 0 表示表达式中没有常数项。

因此,brillig 函数接收 x - y 作为输入,并返回一个中间值,存储在 Witness(2)

但是,在 brillig func 0 函数内部发生了什么?

unconstrained func 0 [
  Const { destination: Direct(21), bit_size: Integer(U32), value: 1 },
  Const { destination: Direct(20), bit_size: Integer(U32), value: 0 },
  CalldataCopy { destination_address: Direct(0), size_address: Direct(21), offset_address: Direct(20) },
  Const { destination: Direct(2), bit_size: Field, value: 0 },
  BinaryFieldOp { destination: Direct(3), op: Equals, lhs: Direct(0), rhs: Direct(2) },
  JumpIf { condition: Direct(3), location: 8 },
  Const { destination: Direct(1), bit_size: Field, value: 1 },
  BinaryFieldOp { destination: Direct(0), op: Div, lhs: Direct(1), rhs: Direct(0) },
  Stop { return_data: HeapVector { pointer: Direct(20), size: Direct(21) } }
]

每一行表示在提供的顺序中在 Brillig VM 中执行的 brillig 指令:

  1. 存储常量
Const { destination: Direct(21), bit_size: Integer(U32), value: 1 }
Const { destination: Direct(20), bit_size: Integer(U32), value: 0 }

将常量 1 存储在内存位置 21,常量 0 存储在内存位置 20。这些常量将用于后续的复制和断言操作。

  1. 从参数中加载 x - y 到槽 0
CalldataCopy {
  destination_address: Direct(0),
  size_address: Direct(21),
  offset_address: Direct(20)
}

size_address: Direct(21) 表示我们正在复制 1 个元素(因为 1 之前存储在槽 21 中)。

offset_address: Direct(20) 表示我们从位置 0 开始复制(因为 0 之前存储在槽 20 中)。

  1. 检查 x - y == 0
Const { destination: Direct(2), bit_size: Field, value: 0 }
BinaryFieldOp { destination: Direct(3), op: Equals, lhs: Direct(0), rhs: Direct(2) }

将常量 0 存储在槽 2,并进一步作为 Equals 操作码的比较部分。比较的结果(如果 x - y == 0 则为 1,否则为 0)存储在槽 3

  1. 有条件跳转以停止执行
JumpIf { condition: Direct(3), location: 8 }

如果 x — y == 0(结果存储在槽 3),执行跳转到 Stop 操作码。这防止了在下一步中出现除以零的情况。

  1. 计算 1 / (x - y)
Const { destination: Direct(1), bit_size: Field, value: 1 }
BinaryFieldOp { destination: Direct(0), op: Div, lhs: Direct(1), rhs: Direct(0) }

在内存位置 1 中存储常量 1,并在由 Div 操作码引发的除法操作中用作被除数。结果存储在槽 0 中。

  1. 停止执行并返回
Stop { return_data: HeapVector { pointer: Direct(20), size: Direct(21) } }

Stop 指令终止执行。在 return_data 中,我们看到返回的数据来自内存位置 0,大小为 1(取自自第 1 步中设置的 Direct(20) 和 Direct(21))。但内存位置 0 存储了什么?

  • 如果 x — y == 0,则返回 x(见步骤 2);
  • 否则,返回值为 1 / (x - y)(见步骤 5)。

所以,brillig 调用的主要目的是计算 x - y 的模逆。下一个 ACIR 表达式向我们展示了为什么值是必要的。

EXPR [ (1, _0, _2) (-1, _1, _2) -1 ]

这就是检查发生的地方。因为 brillig 调用不生成任何约束,只是计算一些值,所以我们面临着获取未约束错误的风险。Noir 通过约束上述 brillig func 0 的返回值来修复这一点。

ACIR 中的 EXPR 操作码表示以下形式的线性约束:

image-20240930222847819.png

其中 c_i 是系数,w_i 是证明,q_c 是常数。

现在让我们将其与我们实际的表达式匹配:

image-20240930222847819.png

用输入替换 w_i 的值(w_0 = x, w_1 = y)和中间值(w_2 = 1 / (x - y)),得到:

image-20240930222847819.png

在将 1 / (x - y) 提取后,公式看起来像这样:

image-20240930222847819.png

最后,我们达到了只有在 x != y 时,约束才能满足的点。唉!看起来只是为了证明两个值不相等而经历了很多步骤,但这只是 Noir 在内部使用的许多加密技巧之一,以隐藏将业务逻辑重构以适应约束格式的复杂性。

Pedersen Hash 电路

给定两个字段元素的输入数组,此电路计算 Pedersen 哈希。编译后的 ACIR 输出揭示了 Noir 如何将其转换为约束、brillig 和黑盒操作。

一般电路信息看起来像这样:

Compiled ACIR for main (unoptimized):
func 0
current witness index : 9
private parameters indices : [0, 1]
public parameters indices : []
return value indices : [2]

current index witness 显示我们有 10 个证明:

  • witness 0 和 witness 1 → 私有输入数组的值;
  • 输出证明 2 → Pedersen 哈希的结果;
  • 7 个中间证明。

ACIR 调用第一次执行 func 0 花了两个 brillig 调用。它们是相同的,所以让我们检查第一个:

BRILLIG CALL func 0:
  inputs: [
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [(1, Witness(0))],
        q_c: 0
      }
    )
  ],
  outputs: [Simple(Witness(3)), Simple(Witness(4))]

这个 brillig 调用处理第一个证明(input[0])并产生两个中间证明:Witness(3) 和 Witness(4)。剧透——输出是输入标量的低位和高位。

unconstrained func 0 [
  Const { destination: Direct(2), bit_size: Integer(U32), value: 1 },
  Const { destination: Direct(1), bit_size: Integer(U32), value: 32839 },
  Const { destination: Direct(0), bit_size: Integer(U32), value: 3 },
  Const { destination: Relative(2), bit_size: Integer(U32), value: 1 },
  Const { destination: Relative(3), bit_size: Integer(U32), value: 0 },
  CalldataCopy { destination_address: Direct(32836), size_address: Relative(2), offset_address: Relative(3) },
  Mov { destination: Relative(1), source: Direct(32836) },
  Call { location: 14 },
  Call { location: 16 },
  Mov { destination: Direct(32837), source: Relative(1) },
  Mov { destination: Direct(32838), source: Relative(2) },
  Const { destination: Relative(3), bit_size: Integer(U32), value: 32837 },
  Const { destination: Relative(4), bit_size: Integer(U32), value: 2 },
  Stop { return_data: HeapVector { pointer: Relative(3), size: Relative(4) } },
  Const { destination: Direct(32772), bit_size: Integer(U32), value: 30720 },
  BinaryIntOp { destination: Direct(32771), op: LessThan, bit_size: U32, lhs: Direct(0), rhs: Direct(32772) },
  JumpIf { condition: Direct(32771), location: 35 },
  IndirectConst { destination_pointer: Direct(1), bit_size: Integer(U64), value: 17843811134343075018 },
  Trap { revert_data: HeapVector { pointer: Direct(1), size: Direct(2) } },
  Return
]

在 brillig 调用中调用的 func 0 看起来像是加密学爱好者的汇编语言,但我们已经熟悉的许多操作码,因此我们不会深入了解之前提到的或显而易见的操作码。

我们已经看过 Direct(x) 访问内存位置,但在这个例子中出现了 Relative(x) 符号。二者有什么区别?

  • Direct(x) → Brillig VM 中的绝对内存地址。
  • Relative(x) → Brillig VM 中的相对堆栈内存地址。它通常用于动态变化的变量。

另一个新指令是 Cast,负责在不同数字格式之间进行类型转换。例如,在我们的 brillig 调用中,它将存储在 Relative(1) 中的值转换为 64 位整数,然后再从 Integer(U64) 转换回字段元素。

但是,即使我们理解大部分的 brillig 指令,理解如此多的操作码在如此低的水平上仍然令人不知所措。幸运的是,有一个命令可以帮助我们更清楚地理解 brillig 函数的目的:

nargo compile --show-brillig

我们在输出中添加了评论,以便更容易理解输入重构:

GlobalInit(Id(7)):
  CONST M32835 = 18446744073709551616 // load constant 2⁶⁴
  RETURN
Function(Id(7), None):
  CALL Procedure(CheckMaxStackDepth) // prevent exceeding stack limits
Function(Id(7), Some(Id(0))):
  CAST S3, S1 as u64 // S3 = low 64 bits
  CAST S2, S3 as u254 // S2 = low 64 bits
  S3 = S1 - S2 // S3 = high 190 bits with zeros
  S1 = S3 f/ M32835 // S1 = high 190 bits
  CAST S4, S1 as u64 // S4 = lowhigh 64 bits
  CAST S3, S4 as u254 // S3 = lowhigh 64 bits
  S4 = S1 - S3 // S4 = high 126 bits with zeros
  S1 = S4 f/ M32835 // S1 = high 126 bits
  S4 = S3 * M32835 // S4 = lowhigh 64 bits with zeros
  S3 = S4 + S2 // S3 = low 128 bits
  MOV S2, S1 // S2 = high 126 bits
  MOV S1, S3 // S1 = low 128 bits
  RETURN

请注意,我们在 254 位域中工作,其中输入标量(w_0 或在 brillig 输出中的 S1)被分为四个 64 位块。注释中的“lowhigh bits”指的是右侧 128 位块的上 64 位。64 位块随后通过一系列步骤进行处理和组合,以重构原始输入值的低 128 位和高 126 位。

因此,函数返回:

  • S1 → w_3 → 低 128 位。
  • S2 → w_4 → 高 126 位。

调用 brillig 函数后的下一步是约束其输出 w_3 和 w_4。在 ACIR 中看起来像这样:

EXPR [ (1, _0) (-1, _3) (-340282366920938463463374607431768211456, _4) 0 ]

它在数学上翻译为:

image-20240930222847819.png

这个约束确保证明 w_0 被正确分割。

对第二个输入(Witness(1))的 Pedersen 哈希函数进行相同操作。

最后,我们进入 Pedersen 哈希的核心——多标量乘法(MSM)。它负责乘以点和标量并汇总结果。

BLACKBOX::MULTI_SCALAR_MUL
  [(3728882899078719075161482178784387565366481897740339799480980287259621149274)...(0)]
  [ _7, _8, _9]

这个黑盒函数内部有什么呢?好吧,这取决于你使用的证明后端,因为 Noir 将这个责任委托给后端。后端则以最优化的方式实现专业约束。这使我们能够使一些 zkSNARK 不友好的计算变得更便宜。

MULTI_SCALAR_MUL 黑盒函数根据 Noir 代码库 的描述接受以下输入:

MultiScalarMul {
    points: HeapVector,
    scalars: HeapVector,
    outputs: HeapArray,
}

但是为什么我们需要将标量输入分成两部分,而不直接使用它们?答案可以在 代码库 中找到:

- input:
  points (witness, N) a vector of x and y coordinates of input
  points `[x1, y1, x2, y2,...]`.
  scalars (witness, N) a vector of low and high limbs of input
  scalars `[s1_low, s1_high, s2_low, s2_high, ...]`. (witness, N)
  For Barretenberg, they must both be less than 128 bits.
- output:
  a tuple of `x` and `y` coordinates of output.
  Points computed as `s_low*P+s_high*2^{128}*P`

所以,我们必须将原始的 256 位标量分为两部分,因为 Barretenberg 要求标量的两部分都小于 128 位。

最后一步是:

EXPR [ (1, _2) (-1, _7) 0 ]

这简单地意味着:w_2 = w_7

为什么是 w_7?因为它对应于 MSM 黑盒函数的第一个输出——结果椭圆曲线点的 x 坐标。而这正是 Pedersen 哈希的含义。约束确保 MSM 结果正确绑定到电路输出 w_2

内存访问与条件执行 电路

image-20240930222847819.png

好吧,是时候看看 Noir 如何将动态内存访问和条件执行编译为 ACIR。

一般信息已经对我们足够熟悉:

Compiled ACIR for main (unoptimized):
func 0
current witness index : 24
private parameters indices : [0, 1, 2, 3, 4, 5, 6]
public parameters indices : []
return value indices : [7]

我们看到的第一个新内容是范围检查:

BLACKBOX::RANGE [(0)] [ ]
BLACKBOX::RANGE [(1)] [ ]

这是一种黑盒函数,因此编译器不会关心它,依赖于后端的实现。编译器的任务只是提及必须对提供的证明执行范围检查。在我们的情况下,我们检查前两个证明是不是 u8。在 ACIR 中可以找到类似的调用,因此我们将省略它们,因为我们已经知道它们的用途。

接下来,我们进行了内存初始化和访问:

INIT (id: 0, len: 5)
MEM (id: 0, read at: x0, value: x8)
MEM (id: 0, read at: x1, value: x14)

INIT 操作码负责初始化 values 输入数组,id 为 0。然后通过 MEM 操作码,我们动态读取 values[index1] → w8values[index2] → w14

但我们不仅从数组中检索值,还将它们从 Field 类型转换为 u32 以便于计算:

let val1 = values[index1] as u32;
let val2 = values[index2] as u32;

这通过 brillig 中的 func 0 处理:

BRILLIG CALL func 0:
  inputs: [
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [(1, Witness(8))],
        q_c: 0
      }
    ),
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [],
        q_c: 4294967296
      }
    )
  ],
  outputs: [Simple(Witness(9)), Simple(Witness(10))]

func 0 不包含任何新操作码,因此我们将跳过检查其内部并假设它:

  • 接收 2 个输入:转换的值和缩放常量 2³²;
  • 返回 2 个证明:商(F / 2³²)和余数(F % 2³²)。

对于 brillig 调用,必须验证返回值:

EXPR [ (1, _8) (-4294967296, _9) (-1, _10) 0 ]

翻译为数学公式:

image-20240930222847819.png

这个方程确保类型转换正确通过如下检查:

original value = (quotient * 2³²) + remainder

对 val2 的转换应用相同逻辑。

我们的下一步是解决条件 val1 > val2

BRILLIG CALL func 0:
  inputs: [
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [(-1, Witness(10)), (1, Witness(16))],
        q_c: 4294967296
      }
    ),
    Single(
      Expression {
        mul_terms: [],
        linear_combinations: [],
        q_c: 4294967296
      }
    )
  ],
  outputs: [Simple(Witness(20)), Simple(Witness(21))]

第一个输入表示 val1 - val2 + 2³²,第二个输入与之前的 func 0 调用一样为 2³²。那么返回值是什么呢?

  • Witness(21) 是输入除以 2³² 的余数((val1 - val2 + 2³²) % 2³²),需要约束 brillig 输出;
  • Witness(20) 更为复杂:
image-20240930222847819.png

由于涉及整数除法,返回值为:

  • 如果 val1 > val2,则为 1(因为 val1 - val2 为正);
  • 否则为 0(因为 val1 - val2 + 2³² 保持在一个 u32 块内)。

这个值是 if 逻辑的关键,因为它充当了分支选择器。

brillig 调用的输出立即通过如下方式验证:

EXPR [ (-1, _10) (1, _16) (-4294967296, _20) (-1, _21) 4294967296 ]
EXPR [ (-1, _10, _20) (-1, _16, _20) (1, _10) (1, _16) (-1, _22) 0 ]

在获取所有中间证明后,是时候进行真正的魔法——条件执行

首先,我们计算乘法结果:

EXPR [ (1, _10, _16) (-1, _23) 0 ]

这相当于 w23 = w10 * w16

之后,我们利用条件标志 w20 选择正确的结果:

EXPR [ (1, _20, _23) (-1, _24) 0 ]

w24 = w20 * w23 表明:

  • 如果 w20 = 1val1 > val2),则 w24 = val1 * val2
  • 如果 w20 = 0,则 w24 = 0

最后,我们计算结果并将其存储在输出 Witness(7) 中:

EXPR [ (1, _10, _20) (1, _16, _20) (-1, _20, _23) (1, _7) (-1, _10) (-1, _16) 0 ]

它扩展为:

image-20240930222847819.png

在 val1 和 val2 的术语中,它看起来像这样:

image-20240930222847819.png
  • 如果 w20 = 1val1 > val2),它简化为 w7 = val1 + val2
  • 如果 w20 = 0val1 <= val2),则简化为 w7 = val1 * val2

结论

现在,掌握这些知识后,我们可以分解几乎任何 Noir 电路的 ACIR。开始时的高级逻辑被重塑为结构化的约束,使其与零知识证明系统兼容。Noir 背后的魔法变得越来越易于理解:其核心原则是将代码转化为纯数学,并通过无约束调用和黑盒函数优化执行。

image-20240930222847819.png

  • 原文链接: medium.com/distributed-l...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

登链社区是一个 Web3 开发者社区,通过构建高质量技术内容平台和线下空间,助力开发者成为更好的 Web3 Builder。
登链社区
  •  登链社区网站 : learnblockchain.cn

  •  开发者技能认证 : decert.me

  •  B 站 : space.bilibili.com/581611011

  •  YouTube : www.youtube.com/@upchain

登链社区

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

登链社区
数据请求中
查看更多

推荐专栏

数据请求中
在 App 打开