Skip to content

Commit b036cf3

Browse files
committed
formal: u64 limb conservation soundness (value gadget)
Mechanize the soundness argument of the circuit's u64 value gadget (opencsv-rs crates/opencsv-pcd/src/value.rs, paper §4.5 item 2) in a new module OpenCsv/Value.lean — plain Lean, no mathlib, sorry-free. Model: three little-endian limbs (24/24/16 bits) matching u64_to_felts; field equality modeled as congruence mod the BabyBear prime p = 2^31 - 2^27 + 1 over the integers (primality never used — only the size bound). carryConstraints is a one-to-one model of enforce_sum_eq: per-limb t_i = 2^24 · c_{i+1} in the field, boolean carries (1-bit decomposition), final carry pinned to zero, uniform radix 2^24. Theorems: - range_checked_represents_exactly: a checked triple represents exactly [0, 2^64) (bounded + injective + surjective). - difference_interval_within_half_field: the key bound (-2^26, 2^26) ⊂ (-p/2, p/2), by decide. - no_wrap: integers in that interval equal mod p are equal, period. - carry_sound: field carry-chain + range checks ⟹ integer sums equal — wrap-around cannot fake balance. - carry_complete_single: honest direction for the mint usage [out0, out1] = [V, 0]; the general 2-vs-2 converse is false (counterexample documented in the module) — completeness limitation only, the prover chooses outputs. Audit: five new #print axioms entries; all depend on Lean core axioms only (propext / Quot.sound / Classical.choice via omega) — no project axioms, no sorryAx. Baseline regenerated; scripts/axiom-audit.sh green. README gains a section mapping the module to value.rs and §4.5 item 2.
1 parent 1dfedd7 commit b036cf3

4 files changed

Lines changed: 296 additions & 1 deletion

File tree

OpenCsv.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import OpenCsv.Interfaces
22
import OpenCsv.State
33
import OpenCsv.Theorems
4+
import OpenCsv.Value

OpenCsv/Value.lean

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/-!
2+
# The u64 conservation gadget (opencsv-rs `crates/opencsv-pcd/src/value.rs`)
3+
4+
Mechanized soundness of the circuit's value representation and carry-chain
5+
sum constraint (paper §4.5 item 2: per-asset conservation over range-checked
6+
values — "wrap-around cannot fake balance").
7+
8+
Model (plain Lean, no mathlib):
9+
10+
* values are three little-endian limbs of 24/24/16 bits (`Limbs`), matching
11+
`opencsv-core`'s `u64_to_felts` encoding;
12+
* the field is modeled as the integers modulo the BabyBear prime
13+
`p = 2^31 − 2^27 + 1`: field equality of two integer representatives is
14+
divisibility of their difference by `p` (`FieldEq`). Primality of `p` is
15+
never used — the soundness argument needs only the size bound, so we do
16+
not prove it.
17+
18+
The circuit (`enforce_sum_eq`) constrains, per limb `i` with carry `c_i`
19+
(`c_0 = 0`):
20+
21+
```text
22+
t_i = lhs[0][i] + lhs[1][i] + c_i − rhs[0][i] − rhs[1][i]
23+
t_i = 2^24 · c_{i+1} (in the field; c_{i+1} ∈ {0,1}; c_3 = 0)
24+
```
25+
26+
using a uniform radix `2^24` on all three limbs — the deliberate 72-bit
27+
carry arithmetic of the Rust doc comment. `carryConstraints` below is a
28+
one-to-one model of these equations.
29+
-/
30+
31+
namespace OpenCsv.Value
32+
33+
/-- The BabyBear prime `p = 2^31 − 2^27 + 1 = 2013265921`. Only its size is
34+
used, never its primality. -/
35+
def babyBear : Nat := 2^31 - 2^27 + 1
36+
37+
/-- Field equality, modeled on integer representatives: `x ≡ y (mod p)`.
38+
This is exactly what an in-circuit equality constraint over BabyBear means
39+
for integer witnesses. -/
40+
def FieldEq (x y : Int) : Prop := (babyBear : Int) ∣ x - y
41+
42+
/-- Three little-endian limbs (24, 24, 16 bits). -/
43+
structure Limbs where
44+
/-- Low limb, 24 bits. -/
45+
l0 : Nat
46+
/-- Middle limb, 24 bits. -/
47+
l1 : Nat
48+
/-- Top limb, 16 bits. -/
49+
l2 : Nat
50+
51+
/-- The circuit's range check (`decompose_to_bits` with 24/24/16 bits):
52+
each limb below its bit width. -/
53+
def rangeChecked (l : Limbs) : Prop := l.l0 < 2^24 ∧ l.l1 < 2^24 ∧ l.l2 < 2^16
54+
55+
/-- The integer a limb triple encodes: `l0 + 2^24·l1 + 2^48·l2`. -/
56+
def encode (l : Limbs) : Nat := l.l0 + 2^24 * l.l1 + 2^48 * l.l2
57+
58+
/-! ## Part 1 — a range-checked triple represents exactly `[0, 2^64)` -/
59+
60+
/-- Every range-checked triple encodes a value below `2^64`. -/
61+
theorem encode_lt {l : Limbs} (h : rangeChecked l) : encode l < 2^64 := by
62+
obtain ⟨hl0, hl1, hl2⟩ := h
63+
simp only [encode]
64+
omega
65+
66+
/-- The encoding is injective on range-checked triples: a checked triple
67+
encodes a *unique* value, and each value has at most one checked triple. -/
68+
theorem encode_injective {l m : Limbs} (hl : rangeChecked l) (hm : rangeChecked m)
69+
(h : encode l = encode m) : l = m := by
70+
obtain ⟨l0, l1, l2⟩ := l
71+
obtain ⟨m0, m1, m2⟩ := m
72+
simp only [rangeChecked, encode] at hl hm h
73+
obtain ⟨hl0, hl1, hl2⟩ := hl
74+
obtain ⟨hm0, hm1, hm2⟩ := hm
75+
-- Peel off one limb at a time: the low limb is determined mod 2^24, etc.
76+
have e0 : l0 = m0 := by omega
77+
have e1 : l1 = m1 := by omega
78+
have e2 : l2 = m2 := by omega
79+
subst e0; subst e1; subst e2; rfl
80+
81+
/-- Every value below `2^64` has a range-checked encoding (the honest
82+
decomposition `u64_to_felts`). -/
83+
theorem encode_surjective {v : Nat} (hv : v < 2^64) :
84+
∃ l : Limbs, rangeChecked l ∧ encode l = v := by
85+
have hdiv : v / 2^24 / 2^24 = v / 2^48 := by rw [Nat.div_div_eq_div_mul]
86+
refine ⟨⟨v % 2^24, (v / 2^24) % 2^24, v / 2^48⟩,
87+
by show v % 2^24 < 2^24; omega,
88+
by show (v / 2^24) % 2^24 < 2^24; omega,
89+
by show v / 2^48 < 2^16; omega⟩, ?_⟩
90+
show v % 2^24 + 2^24 * ((v / 2^24) % 2^24) + 2^48 * (v / 2^48) = v
91+
omega
92+
93+
/-- **Representation is exact** (the rustdoc claim: "a checked limb triple
94+
encodes a unique value in `[0, 2^64)`"): bounded, injective, surjective. -/
95+
theorem range_checked_represents_exactly :
96+
(∀ l : Limbs, rangeChecked l → encode l < 2^64) ∧
97+
(∀ l m : Limbs, rangeChecked l → rangeChecked m → encode l = encode m → l = m) ∧
98+
(∀ v : Nat, v < 2^64 → ∃ l : Limbs, rangeChecked l ∧ encode l = v) :=
99+
fun _l h => encode_lt h, fun _l _m hl hm h => encode_injective hl hm h,
100+
fun _v hv => encode_surjective hv⟩
101+
102+
/-! ## Part 2 — the key bound: `(-2^26, 2^26) ⊂ (-p/2, p/2)` -/
103+
104+
/-- The per-limb difference bound: with limbs below `2^24` and a boolean
105+
incoming carry, the per-limb difference lies in `(-2^26, 2^26)`. (The top
106+
limb's tighter `2^16` bound only makes the interval smaller; we reuse this
107+
lemma there via the implied `2^24` bounds.) -/
108+
theorem per_limb_difference_bound {x0 x1 y0 y1 : Nat} {c : Int}
109+
(hx0 : x0 < 2^24) (hx1 : x1 < 2^24) (hy0 : y0 < 2^24) (hy1 : y1 < 2^24)
110+
(hc : c = 0 ∨ c = 1) :
111+
-(2:Int)^26 < (x0 : Int) + x1 + c - y0 - y1 ∧
112+
(x0 : Int) + x1 + c - y0 - y1 < 2^26 := by
113+
rcases hc with rfl | rfl <;> omega
114+
115+
/-- **The key numerical fact.** The difference interval `(-2^26, 2^26)` lies
116+
strictly inside `(-p/2, p/2)` for the BabyBear prime `p = 2^31 − 2^27 + 1`:
117+
`2^26 < p/2 = 1006632960`. Hence two integers from these intervals that are
118+
equal modulo `p` are equal, period — no modular wrap can satisfy a carry
119+
equation spuriously. -/
120+
theorem difference_interval_within_half_field :
121+
(2:Int)^26 < (babyBear : Int) / 2 := by
122+
have hp : (babyBear : Int) = 2013265921 := by decide
123+
rw [hp]; decide
124+
125+
/-- **No-wrap lemma.** Two integers in `(-2^26, 2^26)` that are equal in the
126+
field are equal as integers: their difference is a multiple of `p` with
127+
absolute value below `2^27 < p`, so it is zero. This is the load-bearing
128+
step of the soundness argument. -/
129+
theorem no_wrap {x y : Int} (hx : -(2:Int)^26 < x ∧ x < 2^26)
130+
(hy : -(2:Int)^26 < y ∧ y < 2^26) (h : FieldEq x y) : x = y := by
131+
have hp : (babyBear : Int) = 2013265921 := by decide
132+
obtain ⟨k, hk⟩ := h
133+
rw [hp] at hk
134+
-- x − y = p·k with |x − y| < 2^27 < p, forcing k = 0.
135+
have hk0 : k = 0 := by omega
136+
omega
137+
138+
/-! ## Part 3 — the carry-chain constraints and their soundness -/
139+
140+
/-- A carry witness for the sum constraint: the four carries of the chain
141+
(`c_0` pinned to zero, `c_3` the final carry, pinned to zero by the
142+
constraints). -/
143+
structure CarryWitness where
144+
/-- Incoming carry of limb 0 (always 0). -/
145+
c0 : Int
146+
/-- Carry out of limb 0 / into limb 1. -/
147+
c1 : Int
148+
/-- Carry out of limb 1 / into limb 2. -/
149+
c2 : Int
150+
/-- Final carry out of limb 2 (pinned to 0: no overflow past the top). -/
151+
c3 : Int
152+
153+
/-- A boolean carry, as enforced in-circuit by a 1-bit decomposition
154+
(`decompose_to_bits(next, 1)`). -/
155+
def isBit (c : Int) : Prop := c = 0 ∨ c = 1
156+
157+
/-- **The constraints of `enforce_sum_eq`, modeled one-to-one.** Per limb,
158+
the difference `lhs[0][i] + lhs[1][i] + c_i − rhs[0][i] − rhs[1][i]` equals
159+
`2^24 · c_{i+1}` *in the field*; each outgoing carry is boolean; the final
160+
carry is pinned to zero. Uniform radix `2^24` on all three limbs, exactly as
161+
in the circuit (including the top, 16-bit limb). -/
162+
def carryConstraints (lhs0 lhs1 rhs0 rhs1 : Limbs) (w : CarryWitness) : Prop :=
163+
w.c0 = 0 ∧ isBit w.c1 ∧ isBit w.c2 ∧ w.c3 = 0
164+
FieldEq ((lhs0.l0 : Int) + lhs1.l0 + w.c0 - rhs0.l0 - rhs1.l0) (2^24 * w.c1) ∧
165+
FieldEq ((lhs0.l1 : Int) + lhs1.l1 + w.c1 - rhs0.l1 - rhs1.l1) (2^24 * w.c2) ∧
166+
FieldEq ((lhs0.l2 : Int) + lhs1.l2 + w.c2 - rhs0.l2 - rhs1.l2) (2^24 * w.c3)
167+
168+
/-- **Carry soundness — the conservation gadget is sound.** If the
169+
carry-chain constraints hold in the field (boolean carries, final carry
170+
zero) and all four values are range-checked, then the integer sums are
171+
equal: `encode lhs0 + encode lhs1 = encode rhs0 + encode rhs1`. Equality
172+
holds over the integers, not just mod `p` — wrap-around cannot fake
173+
balance. -/
174+
theorem carry_sound {lhs0 lhs1 rhs0 rhs1 : Limbs} (w : CarryWitness)
175+
(hl0 : rangeChecked lhs0) (hl1 : rangeChecked lhs1)
176+
(hr0 : rangeChecked rhs0) (hr1 : rangeChecked rhs1)
177+
(h : carryConstraints lhs0 lhs1 rhs0 rhs1 w) :
178+
encode lhs0 + encode lhs1 = encode rhs0 + encode rhs1 := by
179+
obtain ⟨hc0, hc1, hc2, hc3, e0, e1, e2⟩ := h
180+
obtain ⟨ha0, ha1, ha2⟩ := hl0
181+
obtain ⟨hb0, hb1, hb2⟩ := hl1
182+
obtain ⟨hd0, hd1, hd2⟩ := hr0
183+
obtain ⟨he0, he1, he2⟩ := hr1
184+
-- Each field equation lifts to an integer equation via the no-wrap lemma:
185+
-- the per-limb difference and `2^24·c` both lie in `(-2^26, 2^26)`.
186+
have i0 : (lhs0.l0 : Int) + lhs1.l0 + w.c0 - rhs0.l0 - rhs1.l0 = 2^24 * w.c1 :=
187+
no_wrap (per_limb_difference_bound ha0 hb0 hd0 he0 (Or.inl hc0))
188+
(by rcases hc1 with h | h <;> rw [h] <;> constructor <;> omega) e0
189+
have i1 : (lhs0.l1 : Int) + lhs1.l1 + w.c1 - rhs0.l1 - rhs1.l1 = 2^24 * w.c2 :=
190+
no_wrap (per_limb_difference_bound ha1 hb1 hd1 he1 hc1)
191+
(by rcases hc2 with h | h <;> rw [h] <;> constructor <;> omega) e1
192+
have i2 : (lhs0.l2 : Int) + lhs1.l2 + w.c2 - rhs0.l2 - rhs1.l2 = 2^24 * w.c3 :=
193+
no_wrap
194+
(per_limb_difference_bound (by omega) (by omega) (by omega) (by omega) hc2)
195+
(by rw [hc3]; constructor <;> omega) e2
196+
-- Telescope the chain: the carries cancel and the encodings match.
197+
simp only [encode]
198+
omega
199+
200+
/-- **Honest direction (completeness) for the circuit's mint usage**
201+
(`enforce_sum_eq [out0, out1] [V, 0]`): if the integer sums balance against
202+
a single value, a boolean carry witness with final carry zero exists.
203+
204+
The general two-addends-both-sides converse is *false*: `(0,1,0) + (0,0,0)`
205+
and `(2^24−1,0,0) + (1,0,0)` both encode `2^24`, yet the limb-0 difference
206+
is `−2^24`, requiring carry `−1 ∉ {0,1}` — the circuit rejects this
207+
(witness-generation failure). This is a completeness limitation, not a
208+
soundness issue: the prover chooses the outputs and can always pick a
209+
provable split. -/
210+
theorem carry_complete_single {lhs0 lhs1 rhs : Limbs}
211+
(hl0 : rangeChecked lhs0) (hl1 : rangeChecked lhs1) (hr : rangeChecked rhs)
212+
(hbal : encode lhs0 + encode lhs1 = encode rhs + encode ⟨0, 0, 0⟩) :
213+
∃ w : CarryWitness, carryConstraints lhs0 lhs1 rhs ⟨0, 0, 0⟩ w := by
214+
obtain ⟨ha0, ha1, ha2⟩ := hl0
215+
obtain ⟨hb0, hb1, hb2⟩ := hl1
216+
obtain ⟨hd0, hd1, hd2⟩ := hr
217+
-- The balance equation over the integers, limb by limb.
218+
have hbalI : (lhs0.l0 : Int) + 2^24 * lhs0.l1 + 2^48 * lhs0.l2
219+
+ (lhs1.l0 + 2^24 * lhs1.l1 + 2^48 * lhs1.l2)
220+
= (rhs.l0 : Int) + 2^24 * rhs.l1 + 2^48 * rhs.l2 := by
221+
have h := hbal
222+
simp only [encode] at h
223+
omega
224+
-- Per-limb differences `u_i` and the natural carries of the balanced
225+
-- subtraction: `c1 = −(u1 + 2^24·u2)`, `c2 = −u2`.
226+
generalize hu1 : ((lhs0.l1 : Int) + lhs1.l1 - rhs.l1) = u1
227+
generalize hu2 : ((lhs0.l2 : Int) + lhs1.l2 - rhs.l2) = u2
228+
-- The carries are boolean: the limb-0 difference is `2^24·c1` and lies in
229+
-- `(-2^24, 2^25)`, forcing `c1 ∈ {0,1}`; then the limb-1 difference with
230+
-- incoming carry is `2^24·c2` in the same interval, forcing `c2 ∈ {0,1}`.
231+
have hc1 : -(u1 + 2^24 * u2) = 0 ∨ -(u1 + 2^24 * u2) = 1 := by omega
232+
have hc2 : -u2 = 0 ∨ -u2 = 1 := by omega
233+
-- The three carry equations hold as integer equalities (hence in the field).
234+
refine ⟨⟨0, -(u1 + 2^24 * u2), -u2, 0⟩, rfl, hc1, hc2, rfl, ?_, ?_, ?_⟩
235+
· show FieldEq ((lhs0.l0 : Int) + lhs1.l0 + 0 - rhs.l0 - 0)
236+
(2^24 * (-(u1 + 2^24 * u2)))
237+
exact ⟨0, by omega⟩
238+
· show FieldEq ((lhs0.l1 : Int) + lhs1.l1 + (-(u1 + 2^24 * u2)) - rhs.l1 - 0)
239+
(2^24 * (-u2))
240+
exact ⟨0, by omega⟩
241+
· show FieldEq ((lhs0.l2 : Int) + lhs1.l2 + (-u2) - rhs.l2 - 0) (2^24 * 0)
242+
exact ⟨0, by omega⟩
243+
244+
/-! ## Axiom audit -/
245+
246+
#print axioms range_checked_represents_exactly
247+
#print axioms difference_interval_within_half_field
248+
#print axioms no_wrap
249+
#print axioms carry_sound
250+
#print axioms carry_complete_single
251+
252+
end OpenCsv.Value

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ every cryptographic hardness assumption is an explicitly labeled `axiom` in
2828
## Layout
2929

3030
```
31-
OpenCsv.lean # root: imports the three modules
31+
OpenCsv.lean # root: imports the modules
3232
OpenCsv/Interfaces.lean # §6 item 1 — abstract crypto interfaces + ALL assumptions
3333
OpenCsv/State.lean # §6 item 2 — coin state machine (valid traces)
3434
OpenCsv/Theorems.lean # §6 item 3 — theorems T1–T4 + corollaries
35+
OpenCsv/Value.lean # the u64 limb/carry conservation gadget (value.rs)
3536
```
3637

3738
## What the theorems say, and what they correspond to
@@ -159,6 +160,42 @@ raw nullifier off-chain (consignments/proofs only). In the model:
159160
- **Proof.** One application of the soundness field, then the `snoc`
160161
constructor of `ValidTrace`.
161162

163+
### The u64 conservation gadget (`OpenCsv/Value.lean`: `encode_lt`,
164+
`encode_injective`, `encode_surjective`, `range_checked_represents_exactly`,
165+
`per_limb_difference_bound`, `difference_interval_within_half_field`,
166+
`no_wrap`, `carry_sound`, `carry_complete_single`)
167+
168+
- **Statement.** Coin values are u64, decomposed in-circuit into three
169+
little-endian limbs of 24/24/16 bits. (i) A range-checked triple (each limb
170+
below its width) represents *exactly* the integers `[0, 2^64)`: bounded,
171+
injective, surjective (`range_checked_represents_exactly`). (ii) The sum
172+
constraint's carry chain — per limb `t_i = lhs[0][i] + lhs[1][i] + c_i −
173+
rhs[0][i] − rhs[1][i]`, `t_i = 2^24·c_{i+1}` in the field, boolean carries,
174+
final carry zero, uniform radix `2^24` — is sound: if it holds, the integer
175+
sums are equal (`carry_sound`). (iii) The key lemma: per-limb differences
176+
lie in `(-2^26, 2^26) ⊂ (-p/2, p/2)` for the BabyBear prime
177+
`p = 2^31 − 2^27 + 1` (`difference_interval_within_half_field`), so field
178+
equality of the carry equations is integer equality — wrap-around cannot
179+
fake balance (`no_wrap`). (iv) The honest direction holds for the mint
180+
usage `[out0, out1] = [V, 0]` (`carry_complete_single`); the general
181+
two-addends-both-sides converse is false (the module documents the
182+
counterexample `(0,1,0) + 0 = (2^24−1,0,0) + (1,0,0)`, which needs carry
183+
`−1`) — a completeness limitation only, since the prover chooses outputs.
184+
- **Paper.** §4.5 item 2 (conservation with range-checked values: "sums are
185+
computed over opened witness values with per-value range checks, so
186+
wrap-around cannot fake balance").
187+
- **Rust.** `crates/opencsv-pcd/src/value.rs` (`u64_to_felts`,
188+
`range_check_value`, `enforce_sum_eq``carryConstraints` in the Lean
189+
module is a one-to-one model of that function's equations), used by
190+
`mint.rs` and `node.rs`. Field equality is modeled as congruence modulo
191+
`p = 2013265921` over the integers; primality of `p` is never used (only
192+
the size bound), so the module needs **no project axioms at all** — the
193+
axiom audit shows only Lean core axioms (`propext`/`Quot.sound`/
194+
`Classical.choice`, via `omega`).
195+
- **Proof.** All arithmetic is closed by `omega` over `Int`/`Nat` (including
196+
div/mod by literal radices); the only manual steps are the no-wrap
197+
divisibility argument and the carry telescope.
198+
162199
## Where the cryptographic hardness lives (complete list)
163200

164201
All in `OpenCsv/Interfaces.lean`:

axiom-audit.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,8 @@
1010
'OpenCsv.receiver_correctness' depends on axioms: [propext, OpenCsv.Sig, OpenCsv.SigVerify, OpenCsv.assetId, OpenCsv.bindHash, OpenCsv.commitHash, OpenCsv.nullHash, OpenCsv.ownerKey]
1111
'OpenCsv.transfer_conservation' depends on axioms: [propext, OpenCsv.Sig, OpenCsv.SigVerify, OpenCsv.assetId, OpenCsv.commitHash, OpenCsv.nullHash, OpenCsv.ownerKey, Quot.sound]
1212
'OpenCsv.unknowing_adversary_entry_invisible' depends on axioms: [propext, OpenCsv.KnowsRawNf, OpenCsv.bindHash, OpenCsv.occurrence_requires_knowledge]
13+
'OpenCsv.Value.carry_complete_single' depends on axioms: [propext, Quot.sound]
14+
'OpenCsv.Value.carry_sound' depends on axioms: [propext, Classical.choice, Quot.sound]
15+
'OpenCsv.Value.difference_interval_within_half_field' does not depend on any axioms
16+
'OpenCsv.Value.no_wrap' depends on axioms: [propext, Quot.sound]
17+
'OpenCsv.Value.range_checked_represents_exactly' depends on axioms: [propext, Quot.sound]

0 commit comments

Comments
 (0)