Skip to content

Commit b4f31da

Browse files
rootclaude
andcommitted
fix(intent): address bugbot and codex review findings
- evalIntent: reject non-void binding functions (Codex #4) - Eval eq/ne: return none for cross-type comparisons (Bugbot #5) - Circom: unique signal prefixes for repeated helper calls (Bugbot #13) - isUint256Expr: check constant value instead of assuming uint256 (Bugbot #10) - dedup: preserve first-occurrence order for Poseidon inputs (Bugbot #11) - Remove dead code: remapCtx, countEmitsExpr (Bugbot #12, #30) - compileStmts .call: skip non-void functions (Codex #24) - compileExpr .call: emit error comment for missing/bodyless fns (Codex #7) - validateParams: enforce positional order matching ABI (Codex #21) - evalConstInt: fuel-bounded to prevent cycles (Codex #22) - Update E2E test Poseidon orderings to match new dedup behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 66991c4 commit b4f31da

5 files changed

Lines changed: 129 additions & 121 deletions

File tree

Compiler/Circom.lean

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,19 @@ def EmitState.addLine (st : EmitState) (line : String) : EmitState :=
6363

6464
/-! ## Constant Folding -/
6565

66-
/-- Try to evaluate a constant expression to an integer value at compile time. -/
67-
partial def evalConstInt (consts : List ConstDef) : Expr → Option Int
68-
| .intLit n => some n
69-
| .param name =>
70-
match consts.find? (fun c => c.name == name) with
71-
| some c => evalConstInt consts c.value
72-
| none => none
73-
| _ => none
66+
/-- Try to evaluate a constant expression to an integer value at compile time.
67+
Uses fuel bounded by the number of constants to guard against cycles. -/
68+
def evalConstInt (consts : List ConstDef) (expr : Expr) : Option Int :=
69+
go consts (consts.length + 1) expr
70+
where
71+
go (consts : List ConstDef) : Nat → Expr → Option Int
72+
| _, .intLit n => some n
73+
| 0, _ => none -- fuel exhausted (cycle in constants)
74+
| fuel + 1, .param name =>
75+
match consts.find? (fun c => c.name == name) with
76+
| some c => go consts fuel c.value
77+
| none => none
78+
| _, _ => none
7479

7580
/-- Split a 256-bit integer into lo/hi 128-bit limbs (as Nat). -/
7681
def splitUint256 (n : Int) : Nat × Nat :=
@@ -116,11 +121,6 @@ def remapStmtParams (fnPfx : String) (paramNames : List String) : Stmt → Stmt
116121
| .call fn args =>
117122
.call fn (args.map (remapParams fnPfx paramNames))
118123

119-
/-- Remap a ParamCtx: add function prefix to param names. -/
120-
def remapCtx (fnPfx : String) (paramNames : List String) (ctx : ParamCtx) : ParamCtx :=
121-
ctx.map (fun (n, t) =>
122-
if paramNames.contains n then (s!"{fnPfx}_{n}", t) else (n, t))
123-
124124
/-! ## Helpers for uint256 signal resolution -/
125125

126126
/-- Check if an expression refers to a uint256 value. -/
@@ -129,8 +129,9 @@ def isUint256Expr (ctx : ParamCtx) (consts : List ConstDef) : Expr → Bool
129129
match ctx.findType name with
130130
| some .uint256 | some .int256 => true
131131
| _ =>
132-
match consts.find? (fun c => c.name == name) with
133-
| some _ => true -- constants are typically uint256
132+
-- Check if it's a constant whose value needs uint256 (lo/hi) treatment
133+
match evalConstInt consts (.param name) with
134+
| some n => n > (2 ^ 128 : Nat) || n < 0
134135
| none => false
135136
| .intLit n => n > (2 ^ 128 : Nat) || n < 0
136137
| _ => false
@@ -300,14 +301,19 @@ partial def compileExpr (st : EmitState) (ctx : ParamCtx)
300301
| some fn =>
301302
match fn.expr with
302303
| some body =>
303-
-- Type-aware argument binding: uint256 params need lo/hi signals
304-
let st := bindFnArgs st ctx fns consts fnName fn.params args
305-
let fnCtx : ParamCtx := fn.params.map (fun (n, t) => (s!"{fnName}_{n}", t))
304+
-- Use a unique prefix to avoid duplicate signals when the same helper is called twice
305+
let (uniquePfx, st) := st.fresh fnName
306+
let st := bindFnArgs st ctx fns consts uniquePfx fn.params args
307+
let fnCtx : ParamCtx := fn.params.map (fun (n, t) => (s!"{uniquePfx}_{n}", t))
306308
let paramNames := fn.params.map (·.1)
307-
let remappedBody := remapParams fnName paramNames body
309+
let remappedBody := remapParams uniquePfx paramNames body
308310
compileExpr st (fnCtx ++ ctx) fns consts remappedBody
309-
| none => ("0", st)
310-
| none => ("0", st)
311+
| none =>
312+
let st := st.addLine s!" // ERROR: function '{fnName}' has no expression body"
313+
("0", st)
314+
| none =>
315+
let st := st.addLine s!" // ERROR: undefined function '{fnName}'"
316+
("0", st)
311317
-- index and length are not directly compilable to circuits in general;
312318
-- they are resolved during forEach unrolling. If reached here, emit 0.
313319
| .index _ _ => ("0", st)
@@ -389,13 +395,19 @@ partial def compileStmts (st : EmitState) (ctx : ParamCtx)
389395
| .call fnName args =>
390396
match fns.find? (fun f => f.name == fnName) with
391397
| some fn =>
392-
let st := bindFnArgs st ctx fns consts fnName fn.params args
393-
let fnCtx : ParamCtx := fn.params.map (fun (n, t) => (s!"{fnName}_{n}", t))
394-
let paramNames := fn.params.map (·.1)
395-
let remappedBody := fn.body.map (remapStmtParams fnName paramNames)
396-
let (st, callPaths, nextIdx) := compileStmts st (fnCtx ++ ctx) fns consts remappedBody condSig nextTemplateIdx
397-
let (st, restPaths, nextIdx) := compileStmts st ctx fns consts rest condSig nextIdx
398-
(st, callPaths ++ restPaths, nextIdx)
398+
-- Only inline void functions; non-void helpers are expression-level
399+
if fn.returnKind != .void then
400+
compileStmts st ctx fns consts rest condSig nextTemplateIdx
401+
else
402+
-- Use a unique prefix to avoid duplicate signals when the same helper is called twice
403+
let (uniquePfx, st) := st.fresh fnName
404+
let st := bindFnArgs st ctx fns consts uniquePfx fn.params args
405+
let fnCtx : ParamCtx := fn.params.map (fun (n, t) => (s!"{uniquePfx}_{n}", t))
406+
let paramNames := fn.params.map (·.1)
407+
let remappedBody := fn.body.map (remapStmtParams uniquePfx paramNames)
408+
let (st, callPaths, nextIdx) := compileStmts st (fnCtx ++ ctx) fns consts remappedBody condSig nextTemplateIdx
409+
let (st, restPaths, nextIdx) := compileStmts st ctx fns consts rest condSig nextIdx
410+
(st, callPaths ++ restPaths, nextIdx)
399411
| none =>
400412
compileStmts st ctx fns consts rest condSig nextTemplateIdx
401413

@@ -414,9 +426,13 @@ private def indexed (xs : List α) : List (Nat × α) :=
414426
| i, x :: rest => (i, x) :: go (i + 1) rest
415427
go 0 xs
416428

417-
private def dedup : List String → List String
418-
| [] => []
419-
| x :: xs => if xs.contains x then dedup xs else x :: dedup xs
429+
private def dedup (xs : List String) : List String :=
430+
let rec go : List String → List String → List String
431+
| [], _ => []
432+
| x :: rest, seen =>
433+
if seen.contains x then go rest seen
434+
else x :: go rest (x :: seen)
435+
go xs []
420436

421437
/-- Generate Circom source for a single intent binding.
422438
`selectorHex` is the 4-byte selector as a hex string (e.g. "0xa9059cbb").

Verity/Intent/Eval.lean

Lines changed: 44 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,21 @@ def evalExpr (fns : List FnDecl) (consts : List ConstDef) (env : Env)
104104
| Expr.eq a b => do
105105
let va ← evalExpr fns consts env fuel' a
106106
let vb ← evalExpr fns consts env fuel' b
107-
some (.bool (va == vb))
107+
match va, vb with
108+
| .int x, .int y => some (.bool (x == y))
109+
| .str x, .str y => some (.bool (x == y))
110+
| .bool x, .bool y => some (.bool (x == y))
111+
| .addr x, .addr y => some (.bool (x == y))
112+
| _, _ => none -- cross-type equality is undefined
108113
| Expr.ne a b => do
109114
let va ← evalExpr fns consts env fuel' a
110115
let vb ← evalExpr fns consts env fuel' b
111-
some (.bool (va != vb))
116+
match va, vb with
117+
| .int x, .int y => some (.bool (x != y))
118+
| .str x, .str y => some (.bool (x != y))
119+
| .bool x, .bool y => some (.bool (x != y))
120+
| .addr x, .addr y => some (.bool (x != y))
121+
| _, _ => none -- cross-type inequality is undefined
112122
| Expr.lt a b => do
113123
let va ← evalExpr fns consts env fuel' a
114124
let vb ← evalExpr fns consts env fuel' b
@@ -259,7 +269,9 @@ def evalIntent (spec : IntentSpec) (binding : IntentBinding)
259269
(params : List (String × Value)) (fuel : Nat := defaultFuel)
260270
: Option (List EmittedTemplate) := do
261271
let fn ← findFn spec.fns binding.intentFn
262-
if fn.params.length != params.length then none
272+
-- Only void functions emit templates; non-void (bool/string) are helpers
273+
if fn.returnKind != .void then none
274+
else if fn.params.length != params.length then none
263275
else
264276
let env : Env := params
265277
evalStmts spec.fns spec.constants env fuel fn.body
@@ -324,9 +336,13 @@ def paramCircuitValues (env : Env) (paramName : String) (ty : ParamType) : Optio
324336
| _ => none
325337

326338
/-- Dedup preserving first-occurrence order (same as Circom.dedup). -/
327-
private def dedup : List String → List String
328-
| [] => []
329-
| x :: xs => if xs.contains x then dedup xs else x :: dedup xs
339+
private def dedup (xs : List String) : List String :=
340+
let rec go : List String → List String → List String
341+
| [], _ => []
342+
| x :: rest, seen =>
343+
if seen.contains x then go rest seen
344+
else x :: go rest (x :: seen)
345+
go xs []
330346

331347
/-- Circuit output: the values that the Circom circuit commits to. -/
332348
structure CircuitOutput where
@@ -337,54 +353,27 @@ structure CircuitOutput where
337353
holeValues : List Nat
338354
deriving Repr
339355

340-
mutual
341-
342-
/-- Count total emit paths in an expression (for void function calls).
343-
Same traversal as Circom's compileStmts — needed for index agreement. -/
344-
def countEmitsExpr (fns : List FnDecl) (consts : List ConstDef)
345-
(fuel : Nat) (expr : Expr) : Nat :=
346-
match fuel with
347-
| 0 => 0
348-
| fuel' + 1 =>
349-
match expr with
350-
| .call fnName _args =>
351-
match findFn fns fnName with
352-
| some fn =>
353-
match fn.returnKind with
354-
| .void => countEmitsStmts fns consts fuel' fn.body
355-
| _ => 0
356-
| none => 0
357-
| _ => 0
358-
359356
/-- Count total emit paths in a statement list (same traversal as Circom's compileStmts).
360357
Counts ALL paths, not just the one taken — needed for consistent index assignment. -/
361-
def countEmitsStmts (fns : List FnDecl) (consts : List ConstDef)
362-
(fuel : Nat) : List Stmt → Nat
358+
partial def countEmitsStmts (fns : List FnDecl) (consts : List ConstDef)
359+
: List Stmt → Nat
363360
| [] => 0
364-
| stmts =>
365-
match fuel with
366-
| 0 => 0
367-
| fuel' + 1 =>
368-
match stmts with
369-
| [] => 0
370-
| .emit _ :: rest => 1 + countEmitsStmts fns consts fuel' rest
371-
| .ite _cond thenBr elseBr :: rest =>
372-
countEmitsStmts fns consts fuel' thenBr +
373-
countEmitsStmts fns consts fuel' elseBr +
374-
countEmitsStmts fns consts fuel' rest
375-
| .forEach _var _array body :: rest =>
376-
-- forEach emits are counted once (for the body pattern);
377-
-- actual unrolling happens at runtime/circuit compile time
378-
countEmitsStmts fns consts fuel' body +
379-
countEmitsStmts fns consts fuel' rest
380-
| .call fnName _args :: rest =>
381-
match findFn fns fnName with
382-
| some fn =>
383-
countEmitsStmts fns consts fuel' fn.body +
384-
countEmitsStmts fns consts fuel' rest
385-
| none => countEmitsStmts fns consts fuel' rest
386-
387-
end
361+
| .emit _ :: rest => 1 + countEmitsStmts fns consts rest
362+
| .ite _cond thenBr elseBr :: rest =>
363+
countEmitsStmts fns consts thenBr +
364+
countEmitsStmts fns consts elseBr +
365+
countEmitsStmts fns consts rest
366+
| .forEach _var _array body :: rest =>
367+
-- forEach emits are counted once (for the body pattern);
368+
-- actual unrolling happens at runtime/circuit compile time
369+
countEmitsStmts fns consts body +
370+
countEmitsStmts fns consts rest
371+
| .call fnName _args :: rest =>
372+
match findFn fns fnName with
373+
| some fn =>
374+
countEmitsStmts fns consts fn.body +
375+
countEmitsStmts fns consts rest
376+
| none => countEmitsStmts fns consts rest
388377

389378
/-- Evaluate an intent and return the circuit-compatible output.
390379
This computes the same `(templateId, holeValues)` that the Circom
@@ -434,8 +423,8 @@ where
434423
findEmitIndex spec fn env fuel' rest target (startIdx + 1)
435424
| none => findEmitIndex spec fn env fuel' rest target (startIdx + 1)
436425
| .ite cond thenBranch elseBranch :: rest =>
437-
let thenCount := countEmitsStmts spec.fns spec.constants fuel' thenBranch
438-
let elseCount := countEmitsStmts spec.fns spec.constants fuel' elseBranch
426+
let thenCount := countEmitsStmts spec.fns spec.constants thenBranch
427+
let elseCount := countEmitsStmts spec.fns spec.constants elseBranch
439428
match evalExpr spec.fns spec.constants env fuel' cond with
440429
| some (.bool true) =>
441430
match findEmitIndex spec fn env fuel' thenBranch target startIdx with
@@ -447,7 +436,7 @@ where
447436
| none => findEmitIndex spec fn env fuel' rest target (startIdx + thenCount + elseCount)
448437
| _ => none
449438
| .forEach _var _array body :: rest =>
450-
let bodyCount := countEmitsStmts spec.fns spec.constants fuel' body
439+
let bodyCount := countEmitsStmts spec.fns spec.constants body
451440
-- forEach circuit output not yet supported — skip the body emits
452441
findEmitIndex spec fn env fuel' rest target (startIdx + bodyCount)
453442
| .call fnName args :: rest =>
@@ -457,7 +446,7 @@ where
457446
match argVals with
458447
| some vals =>
459448
let callEnv := List.zipWith (fun (name, _) val => (name, val)) calledFn.params vals
460-
let callCount := countEmitsStmts spec.fns spec.constants fuel' calledFn.body
449+
let callCount := countEmitsStmts spec.fns spec.constants calledFn.body
461450
match findEmitIndex spec calledFn callEnv fuel' calledFn.body target startIdx with
462451
| some idx => some idx
463452
| none => findEmitIndex spec fn env fuel' rest target (startIdx + callCount)

Verity/Intent/Example.lean

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ test (`test_circom_e2e.sh`) hard-codes as Poseidon inputs.
330330

331331
private def max128 : Nat := (2 ^ 128) - 1
332332

333-
-- Test: transfer(to=0xdead, amount=1000) → templateIdx=1 (else branch), holes=[1000, 0, 57005]
333+
-- Test: transfer(to=0xdead, amount=1000) → templateIdx=1 (else branch), holes=[57005, 1000, 0]
334+
-- Hole order: dedup first-occurrence of [to] ++ [amount, to] = [to, amount] → [to, amount_lo, amount_hi]
334335
#eval do
335336
let spec := erc20IntentSpec
336337
match getBinding spec 0 with
@@ -343,13 +344,14 @@ private def max128 : Nat := (2 ^ 128) - 1
343344
| some co =>
344345
unless co.templateIdx == 1 do
345346
throw (IO.userError s!"expected templateIdx=1, got {co.templateIdx}")
346-
unless co.holeValues == [1000, 0, 57005] do
347-
throw (IO.userError s!"expected holeValues=[1000, 0, 57005], got {repr co.holeValues}")
347+
unless co.holeValues == [57005, 1000, 0] do
348+
throw (IO.userError s!"expected holeValues=[57005, 1000, 0], got {repr co.holeValues}")
348349
IO.println s!"✓ Circuit output: transfer(1000) → templateIdx={co.templateIdx}, holes={repr co.holeValues}"
349350
| none => throw (IO.userError "evalIntentCircuitOutput returned none")
350351
| none => throw (IO.userError "binding not found")
351352

352-
-- Test: transfer(to=0xdead, amount=MAX) → templateIdx=0 (then branch), holes=[max128, max128, 57005]
353+
-- Test: transfer(to=0xdead, amount=MAX) → templateIdx=0 (then branch), holes=[57005, max128, max128]
354+
-- Hole order: [to, amount_lo, amount_hi]
353355
#eval do
354356
let spec := erc20IntentSpec
355357
match getBinding spec 0 with
@@ -362,8 +364,8 @@ private def max128 : Nat := (2 ^ 128) - 1
362364
| some co =>
363365
unless co.templateIdx == 0 do
364366
throw (IO.userError s!"expected templateIdx=0, got {co.templateIdx}")
365-
unless co.holeValues == [max128, max128, 57005] do
366-
throw (IO.userError s!"expected holeValues=[max128, max128, 57005], got {repr co.holeValues}")
367+
unless co.holeValues == [57005, max128, max128] do
368+
throw (IO.userError s!"expected holeValues=[57005, max128, max128], got {repr co.holeValues}")
367369
IO.println s!"✓ Circuit output: transfer(MAX) → templateIdx={co.templateIdx}, holes match"
368370
| none => throw (IO.userError "evalIntentCircuitOutput returned none")
369371
| none => throw (IO.userError "binding not found")
@@ -388,9 +390,9 @@ private def max128 : Nat := (2 ^ 128) - 1
388390
| none => throw (IO.userError "binding not found")
389391

390392
-- Test: transferFrom(fromAddr=0xcafe, to=0xdead, amount=2000)
391-
-- → templateIdx=1 (else branch), holes=[2000, 0, 51966, 57005]
392-
-- Hole order: dedup of [fromAddr, to] ++ [amount, fromAddr, to] = [amount, fromAddr, to]
393-
-- Values: amount_lo=2000, amount_hi=0, fromAddr=0xcafe=51966, to=0xdead=57005
393+
-- → templateIdx=1 (else branch), holes=[51966, 57005, 2000, 0]
394+
-- Hole order: dedup first-occurrence of [fromAddr, to] ++ [amount, fromAddr, to] = [fromAddr, to, amount]
395+
-- Values: fromAddr=0xcafe=51966, to=0xdead=57005, amount_lo=2000, amount_hi=0
394396
#eval do
395397
let spec := erc20IntentSpec
396398
match getBinding spec 2 with
@@ -404,14 +406,15 @@ private def max128 : Nat := (2 ^ 128) - 1
404406
| some co =>
405407
unless co.templateIdx == 1 do
406408
throw (IO.userError s!"expected templateIdx=1, got {co.templateIdx}")
407-
unless co.holeValues == [2000, 0, 51966, 57005] do
408-
throw (IO.userError s!"expected holeValues=[2000, 0, 51966, 57005], got {repr co.holeValues}")
409+
unless co.holeValues == [51966, 57005, 2000, 0] do
410+
throw (IO.userError s!"expected holeValues=[51966, 57005, 2000, 0], got {repr co.holeValues}")
409411
IO.println s!"✓ Circuit output: transferFrom(2000) → templateIdx={co.templateIdx}, holes={repr co.holeValues}"
410412
| none => throw (IO.userError "evalIntentCircuitOutput returned none")
411413
| none => throw (IO.userError "binding not found")
412414

413415
-- Test: transferFrom(fromAddr=0xcafe, to=0xdead, amount=MAX)
414-
-- → templateIdx=0 (then branch), holes=[max128, max128, 51966, 57005]
416+
-- → templateIdx=0 (then branch), holes=[51966, 57005, max128, max128]
417+
-- Hole order: [fromAddr, to, amount_lo, amount_hi]
415418
#eval do
416419
let spec := erc20IntentSpec
417420
match getBinding spec 2 with
@@ -425,8 +428,8 @@ private def max128 : Nat := (2 ^ 128) - 1
425428
| some co =>
426429
unless co.templateIdx == 0 do
427430
throw (IO.userError s!"expected templateIdx=0, got {co.templateIdx}")
428-
unless co.holeValues == [max128, max128, 51966, 57005] do
429-
throw (IO.userError s!"expected holeValues=[max128, max128, 51966, 57005], got {repr co.holeValues}")
431+
unless co.holeValues == [51966, 57005, max128, max128] do
432+
throw (IO.userError s!"expected holeValues=[51966, 57005, max128, max128], got {repr co.holeValues}")
430433
IO.println s!"✓ Circuit output: transferFrom(MAX) → templateIdx={co.templateIdx}, holes match"
431434
| none => throw (IO.userError "evalIntentCircuitOutput returned none")
432435
| none => throw (IO.userError "binding not found")

Verity/Intent/Validate.lean

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,26 @@ private def findFnDecl? (spec : IntentSpec) (name : String) : Option FnDecl :=
3737
spec.fns.find? (fun f => f.name == name)
3838

3939
/-- Validate that intent function params match the ABI function params.
40+
Checks name, type, count, and order (order matters for Poseidon commitment).
4041
Returns a list of error messages. -/
4142
private def validateParams
4243
(bindingName : String)
4344
(intentParams : List (String × ParamType))
4445
(abiParams : List Param) : List String :=
45-
let intentByName := intentParams
4646
let abiByName := abiParams.map (fun p => (p.name, p.ty))
47-
-- Check each intent param exists in ABI with compatible type
48-
let paramErrors := intentByName.filterMap fun (name, intentTy) =>
49-
match abiByName.find? (fun (n, _) => n == name) with
50-
| none => some s!"Binding '{bindingName}': intent param '{name}' not found in ABI function"
51-
| some (_, abiTy) =>
52-
if typesCompatible intentTy abiTy then none
53-
else some s!"Binding '{bindingName}': param '{name}' type mismatch — intent has {repr intentTy}, ABI has {repr abiTy}"
54-
-- Check each ABI param is covered by the intent
55-
let missingErrors := abiByName.filterMap fun (name, _) =>
56-
match intentByName.find? (fun (n, _) => n == name) with
57-
| none => some s!"Binding '{bindingName}': ABI param '{name}' missing from intent function"
58-
| some _ => none
59-
paramErrors ++ missingErrors
47+
-- Check count matches
48+
let countErrors :=
49+
if intentParams.length != abiByName.length then
50+
[s!"Binding '{bindingName}': param count mismatch — intent has {intentParams.length}, ABI has {abiByName.length}"]
51+
else []
52+
-- Check positional match: name, type, and order must all agree
53+
let positionalErrors := (intentParams.zip abiByName).filterMap fun ((iName, iTy), (aName, aTy)) =>
54+
if iName != aName then
55+
some s!"Binding '{bindingName}': param order mismatch — intent has '{iName}' where ABI expects '{aName}'"
56+
else if !typesCompatible iTy aTy then
57+
some s!"Binding '{bindingName}': param '{iName}' type mismatch — intent has {repr iTy}, ABI has {repr aTy}"
58+
else none
59+
countErrors ++ positionalErrors
6060

6161
/-- Collect all function names called in an expression. -/
6262
private def exprCallNames : Expr → List String

0 commit comments

Comments
 (0)