From 8101c54167b641e6e021ee49d264bf86d866ad16 Mon Sep 17 00:00:00 2001 From: shitikyan Date: Fri, 5 Jun 2026 13:47:22 +0400 Subject: [PATCH 1/3] fix(zk): propagate range check to batch_5 + add amount/receiver checks (DEM-756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L2PS batch-5 circuit had a dead-constraint balance guard — `check <== sender_after * sender_after` — that binds nothing. Because `sender_after === sender_before - amount` is BN254 field subtraction, an overdraw wraps `sender_after` to a value about the size of the curve order and the proof still accepts. The same circuit at batch-10 already had the proper `Num2Bits(64)` range check on `sender_after` with an explicit "SECURITY FIX — range check instead of squaring" comment; the fix was never propagated back to batch-5. Plus a related gap on BOTH circuits, surfaced by the PATH-OS L2PS hardening report: only `sender_after` was range-checked. An overflow on the receiver side (`receiver_after`) or an oversized `amount` can also wrap the BN254 field and satisfy the proof. This change: - Replaces the dead constraint on batch_5 with the same `Num2Bits(64)` range check batch_10 already has on `sender_after`. - Adds matching range checks on `amount` and `receiver_after` on BOTH circuits. 64 bits is the documented user-balance limit. - Adds `include "bitify.circom"` to batch_5 (batch_10 already had it). Impact: if anything ever trusts a batch-5 or batch-10 proof on its own (light clients, cross-node aggregation, future bridges), a crafted overdraw / over-receive / oversized-amount transaction can no longer pass verification. The direct executor path already re-validates balances against L1 state, so defence-in-depth on the happy path — but this closes a real soundness hole the moment trust assumptions widen. BLOCKER FOR MERGE: regenerating the proving/verifying keys requires a ZK ceremony. This PR ships the circom source change only. Coordination on the ceremony (rerun trusted setup + commit new `verification_key`) must happen before merge. Source: PATH-OS L2PS hardening report, item 5 (severity: yours to set — defence-in-depth on direct path, critical if anything trusts the batch proof alone). Co-Authored-By: Claude Opus 4.7 --- .../l2ps/zk/circuits/l2ps_batch_10.circom | 21 +++++++++++++----- src/libs/l2ps/zk/circuits/l2ps_batch_5.circom | 22 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom b/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom index 0fda18ded..db0f94142 100644 --- a/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom +++ b/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom @@ -23,12 +23,21 @@ template BalanceTransfer() { sender_after === sender_before - amount; receiver_after === receiver_before + amount; - - // REVIEW: SECURITY FIX - Enforce non-negativity with range check instead of squaring - // sender_after must fit in 64 bits (user balance limit) - component rangeCheck = Num2Bits(64); - rangeCheck.in <== sender_after; - + + // SECURITY FIX — range checks on every magnitude that can cause a + // BN254 field-wrap. The `sender_after` guard was already here; + // `receiver_after` and `amount` are added in DEM-756 (PATH-OS L2PS + // hardening report item 5) so an overflow on the receiver side or + // an oversized amount cannot satisfy the proof. + component senderAfterRange = Num2Bits(64); + senderAfterRange.in <== sender_after; + + component receiverAfterRange = Num2Bits(64); + receiverAfterRange.in <== receiver_after; + + component amountRange = Num2Bits(64); + amountRange.in <== amount; + component preHasher = Poseidon(2); preHasher.inputs[0] <== sender_before; preHasher.inputs[1] <== receiver_before; diff --git a/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom b/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom index ca0b294e7..8889c2267 100644 --- a/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom +++ b/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom @@ -1,5 +1,6 @@ pragma circom 2.1.0; +include "bitify.circom"; include "poseidon.circom"; /* @@ -22,10 +23,23 @@ template BalanceTransfer() { sender_after === sender_before - amount; receiver_after === receiver_before + amount; - - signal check; - check <== sender_after * sender_after; - + + // SECURITY FIX — range checks on every magnitude that can cause a + // BN254 field-wrap. Without these an overdraw makes `sender_after` + // wrap to a value about the size of the curve order and the proof + // still accepts; same for a `receiver_after` overflow or an + // oversized `amount`. Mirrors the guard already present on batch-10 + // — propagated here in DEM-756 / PATH-OS L2PS hardening report + // item 5. 64 bits is the user-balance limit. + component senderAfterRange = Num2Bits(64); + senderAfterRange.in <== sender_after; + + component receiverAfterRange = Num2Bits(64); + receiverAfterRange.in <== receiver_after; + + component amountRange = Num2Bits(64); + amountRange.in <== amount; + component preHasher = Poseidon(2); preHasher.inputs[0] <== sender_before; preHasher.inputs[1] <== receiver_before; From 943757a2de039668b884bf85c495fd3a61aafa7c Mon Sep 17 00:00:00 2001 From: shitikyan Date: Fri, 5 Jun 2026 13:58:43 +0400 Subject: [PATCH 2/3] review: add pre-state range guards (greptile P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 on PR #927: the range-check pass only covered `sender_after`, `receiver_after`, and `amount`. Both pre-state inputs — `sender_before` and `receiver_before` — remained unconstrained, which left two field-arithmetic gaps even with the post-state checks in place: 1. `receiver_before = receiver_after - amount (mod p)` — if `receiver_after < amount` as integers, this wraps to a value near the BN254 order (~2^254). The prover can craft a proof where the receiver "started at −X" and still pass the post-state 64-bit bound on `receiver_after`. 2. `sender_before = sender_after + amount` — with both terms bounded to 64 bits, the integer sum is in `[0, 2^65)`, which exceeds the documented user-balance limit even though every individual signal passes its own check. Bind both pre-state inputs through `Num2Bits(64)` on both circuits. With all five magnitudes (`sender_before`, `receiver_before`, `amount`, `sender_after`, `receiver_after`) bounded to 64 bits and the equality constraints in the middle, field arithmetic collapses to integer arithmetic and a malicious prover can no longer satisfy any of the exploit shapes Greptile described. Constraint order is now: pre-state range checks → equality constraints → post-state range checks. Order is informational only (circom enforces all constraints simultaneously) but reads cleanly top-down. Co-Authored-By: Claude Opus 4.7 --- .../l2ps/zk/circuits/l2ps_batch_10.circom | 26 ++++++++++++------ src/libs/l2ps/zk/circuits/l2ps_batch_5.circom | 27 ++++++++++++------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom b/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom index db0f94142..2bd9ae7fa 100644 --- a/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom +++ b/src/libs/l2ps/zk/circuits/l2ps_batch_10.circom @@ -21,23 +21,33 @@ template BalanceTransfer() { signal output pre_hash; signal output post_hash; + // SECURITY FIX — range checks on every magnitude that can cause a + // BN254 field-wrap. The `sender_after` guard was already here; + // `sender_before`, `receiver_before`, `receiver_after`, and + // `amount` are added in DEM-756 (PATH-OS L2PS hardening report + // item 5). Without the pre-state guards, the equality constraints + // below can be satisfied by field-wrapped pre-state values that + // represent deeply-negative balances; without the post-state guard + // on `receiver_after`, an overflow on the receiver side still + // wraps the field. All five magnitudes get the same 64-bit bound. + component senderBeforeRange = Num2Bits(64); + senderBeforeRange.in <== sender_before; + + component receiverBeforeRange = Num2Bits(64); + receiverBeforeRange.in <== receiver_before; + + component amountRange = Num2Bits(64); + amountRange.in <== amount; + sender_after === sender_before - amount; receiver_after === receiver_before + amount; - // SECURITY FIX — range checks on every magnitude that can cause a - // BN254 field-wrap. The `sender_after` guard was already here; - // `receiver_after` and `amount` are added in DEM-756 (PATH-OS L2PS - // hardening report item 5) so an overflow on the receiver side or - // an oversized amount cannot satisfy the proof. component senderAfterRange = Num2Bits(64); senderAfterRange.in <== sender_after; component receiverAfterRange = Num2Bits(64); receiverAfterRange.in <== receiver_after; - component amountRange = Num2Bits(64); - amountRange.in <== amount; - component preHasher = Poseidon(2); preHasher.inputs[0] <== sender_before; preHasher.inputs[1] <== receiver_before; diff --git a/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom b/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom index 8889c2267..236bba64f 100644 --- a/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom +++ b/src/libs/l2ps/zk/circuits/l2ps_batch_5.circom @@ -21,25 +21,32 @@ template BalanceTransfer() { signal output pre_hash; signal output post_hash; + // SECURITY FIX — range checks on every magnitude that can cause a + // BN254 field-wrap. Without these the equality constraints below + // can be satisfied by field-wrapped pre-state values that + // represent deeply-negative balances (`receiver_before` of + // ~2^254) or by post-state values whose unconstrained sum exceeds + // the documented user-balance limit. All five magnitudes get the + // same 64-bit bound. Propagated here in DEM-756 / PATH-OS L2PS + // hardening report item 5; the equivalent guards live on batch_10. + component senderBeforeRange = Num2Bits(64); + senderBeforeRange.in <== sender_before; + + component receiverBeforeRange = Num2Bits(64); + receiverBeforeRange.in <== receiver_before; + + component amountRange = Num2Bits(64); + amountRange.in <== amount; + sender_after === sender_before - amount; receiver_after === receiver_before + amount; - // SECURITY FIX — range checks on every magnitude that can cause a - // BN254 field-wrap. Without these an overdraw makes `sender_after` - // wrap to a value about the size of the curve order and the proof - // still accepts; same for a `receiver_after` overflow or an - // oversized `amount`. Mirrors the guard already present on batch-10 - // — propagated here in DEM-756 / PATH-OS L2PS hardening report - // item 5. 64 bits is the user-balance limit. component senderAfterRange = Num2Bits(64); senderAfterRange.in <== sender_after; component receiverAfterRange = Num2Bits(64); receiverAfterRange.in <== receiver_after; - component amountRange = Num2Bits(64); - amountRange.in <== amount; - component preHasher = Poseidon(2); preHasher.inputs[0] <== sender_before; preHasher.inputs[1] <== receiver_before; From c4be612ebbdc93f4d9ee2d7260e4b085f9fcb4bb Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Mon, 8 Jun 2026 15:17:17 +0200 Subject: [PATCH 3/3] new verification keys --- .../zk/keys/verification_key_merkle.json | 8 +-- .../zk/keys/batch_10/verification_key.json | 63 +++++++++++++++++++ .../zk/keys/batch_5/verification_key.json | 63 +++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/libs/l2ps/zk/keys/batch_10/verification_key.json create mode 100644 src/libs/l2ps/zk/keys/batch_5/verification_key.json diff --git a/src/features/zk/keys/verification_key_merkle.json b/src/features/zk/keys/verification_key_merkle.json index 984eefe09..08843b8b7 100644 --- a/src/features/zk/keys/verification_key_merkle.json +++ b/src/features/zk/keys/verification_key_merkle.json @@ -37,12 +37,12 @@ ], "vk_delta_2": [ [ - "18153561570497269871709460065656950145845676107830567276428796420018794760878", - "14493154004570182824741014125474402189950594830373587450407265171906210863596" + "3737173237748801916150197263595621610628168774671654222144372062988748744743", + "8494942339891257731708725886258675399312221641975299215004060281298877913879" ], [ - "4710782210617612303757647301552110056852676196608857509244354245942211703732", - "12127673735503044661575851383033478286807800804112705300421952770546123536682" + "19102959287613313576477619996955505571785936093027360591984843514413297332196", + "5967118193880156218070707937439139992656263192195809567901795423269181311521" ], [ "1", diff --git a/src/libs/l2ps/zk/keys/batch_10/verification_key.json b/src/libs/l2ps/zk/keys/batch_10/verification_key.json new file mode 100644 index 000000000..86b83a04e --- /dev/null +++ b/src/libs/l2ps/zk/keys/batch_10/verification_key.json @@ -0,0 +1,63 @@ +{ + "protocol": "plonk", + "curve": "bn128", + "nPublic": 3, + "power": 15, + "k1": "2", + "k2": "3", + "Qm": [ + "8629741523001967825954617229970837044755890655730828018271089887461265720544", + "16661674649614780513875670142226536054735724530315511302554477443453984173492", + "1" + ], + "Ql": [ + "1604132824303807724567232326022194338800368572424805677112108439460745577221", + "14685462059214746899027958127935061556800634666749784625933936277487516126976", + "1" + ], + "Qr": [ + "1639111989347509917951491365886296821785392957726944546256632807685618800414", + "5575657366420788086688689102806866305533174663476803993824008454906372970675", + "1" + ], + "Qo": [ + "194357518203469292879046405180115700077375863678209716394047398831058785264", + "12811711971727353259429571777612224738025696482384367545590506288883269096054", + "1" + ], + "Qc": [ + "3908915865537556123196285596534908564918032581319892141232063803453619525919", + "2313248049840475948631970146861945313989721868392978828930111038692424250085", + "1" + ], + "S1": [ + "8725460839924842402858179664079233720245862108365296612157238326094702728163", + "11465471700321333074145754653405166176426777017875336174560965667838569972606", + "1" + ], + "S2": [ + "20218792744493231308731885077838850121713707604895495848245105125899160028880", + "2892290061526138483601995552588089929423157680611855254187303574238461558101", + "1" + ], + "S3": [ + "13220258831980812701944989660210951961776728127341180097552838351870414500432", + "15313029343777290347424441757656043696295896504263484277990377246219663736845", + "1" + ], + "X_2": [ + [ + "21831381940315734285607113342023901060522397560371972897001948545212302161822", + "17231025384763736816414546592865244497437017442647097510447326538965263639101" + ], + [ + "2388026358213174446665280700919698872609886601280537296205114254867301080648", + "11507326595632554467052522095592665270651932854513688777769618397986436103170" + ], + [ + "1", + "0" + ] + ], + "w": "20402931748843538985151001264530049874871572933694634836567070693966133783803" +} \ No newline at end of file diff --git a/src/libs/l2ps/zk/keys/batch_5/verification_key.json b/src/libs/l2ps/zk/keys/batch_5/verification_key.json new file mode 100644 index 000000000..770ead6ba --- /dev/null +++ b/src/libs/l2ps/zk/keys/batch_5/verification_key.json @@ -0,0 +1,63 @@ +{ + "protocol": "plonk", + "curve": "bn128", + "nPublic": 3, + "power": 14, + "k1": "2", + "k2": "3", + "Qm": [ + "21643112933925468883077310251431478611672488196005271291353611029739504575027", + "20664704920046214505142301568790633609831365212787721810367019700197614390256", + "1" + ], + "Ql": [ + "5687255108447413231067094756527830730199878791418396508712367124904662426213", + "5481915516186262944647089089273485502035879173028302727309863623479726100381", + "1" + ], + "Qr": [ + "19069862647007415805945050858866403985457439228873627187772555517380152986860", + "11999758309956675006182550131557562690866864782159617186148042428370926500854", + "1" + ], + "Qo": [ + "3807665240281547171054824740125036014507261932007043824337138786335013181239", + "3912361646448250042463143032401034351149603808611208508517831279903517356727", + "1" + ], + "Qc": [ + "8226644306840621072619523284737025674942473240599900628765455282150662149192", + "10841148062099040078791995579421657470088881669269469391680438383950249339505", + "1" + ], + "S1": [ + "13695314997885653487948708939090372207129847201020001015296431874119037838470", + "10911903177582983837125760711018979399783546530523052979033326478058634880926", + "1" + ], + "S2": [ + "13237307998033746550578931298010675047196751574377657225294279693203663749891", + "21028431206371602922727764823511764320032402010225854854775735957815571803300", + "1" + ], + "S3": [ + "20524325732737526230791812708520244318359485609406697459183131244378579900023", + "14188663738743915501562483752936432196698772664973769199383262685136107324605", + "1" + ], + "X_2": [ + [ + "21831381940315734285607113342023901060522397560371972897001948545212302161822", + "17231025384763736816414546592865244497437017442647097510447326538965263639101" + ], + [ + "2388026358213174446665280700919698872609886601280537296205114254867301080648", + "11507326595632554467052522095592665270651932854513688777769618397986436103170" + ], + [ + "1", + "0" + ] + ], + "w": "20619701001583904760601357484951574588621083236087856586626117568842480512645" +} \ No newline at end of file