Skip to content

[don't-merge] fix soundness and constraints for formal verification#101

Open
eason1981 wants to merge 12 commits into
mainfrom
formal-verification-fixes
Open

[don't-merge] fix soundness and constraints for formal verification#101
eason1981 wants to merge 12 commits into
mainfrom
formal-verification-fixes

Conversation

@eason1981
Copy link
Copy Markdown
Contributor

@eason1981 eason1981 commented May 20, 2026

Summary

  • LT: a[0] was not constrained to equal lt_signed.result.bit, leaving the output unconstrained in the signed comparison path
  • SR: a[0]/a[1] for SRLW/SRAW were previously unconstrained (TODO comment); now correctly derived from limb_result and higher_limb[1] (the MSB-carrying limb), avoiding sign-extended upper limbs
  • SR: lower_limb[i]/higher_limb[i] were range-checked but never tied back to b[i]; a prover could pick any range-valid limb pair independent of the actual input, producing an arbitrary shift result
  • SR: scalar c (used for shift amount via c_bits/v_0123) and c_for_lookup (used in the CPU multiset lookup) were disconnected; a prover could shift by c while passing off a different c_for_lookup to the CPU, writing a wrong result to the destination register
  • SLL: shift_u16 was not bool-checked and not tied to c_bits[4..6] for SLL/SLLW, allowing a malicious prover to set an arbitrary shift index
  • JALR: bit-0 of the target address was not cleared in the constraint, violating the RISC-V spec
  • DIVREM: LtWordU16Gadget::eval only verifies consistency of the bit witness — it does not assert bit = 1; a malicious prover could supply remainder >= divisor while satisfying all existing constraints
  • DIVREM: quot_msb was never passed to U16MSBGadget::eval, leaving it as a free witness; a prover could forge the sign extension of the quotient's upper limbs in word operations
  • DIVREM: upper limbs of c (c[2], c[3]) were unconstrained in word operations; a prover could set c[2] != 0 while c[0]=c[1]=0, making is_c_0 false and bypassing the division-by-zero special case
  • DIVREM: upper limbs of b (b[2], b[3]) were unconstrained in word operations; a prover could pick a large quotient such that c×q overflows into b[2]/b[3], satisfying c×q+r=b with an incorrect quotient
  • Memory: addr_aligned was a free witness not derived from addr; replaced with an inline computation in eval_memory_access, removing the free column
  • Memory: for LB/LBU, only selected_limb_low_byte was range-checked; the high byte was unconstrained, allowing a prover to break the u8 bound on selected_byte; both bytes are now range-checked together
  • Memory: op_b_access/op_c_access were typed as MemoryReadCols carrying five unused witness columns; replaced with plain Word<T>, removing 18 dead witness columns

- LT: assert a[0] equals lt_signed.result.bit (previously unconstrained)
- SR: constrain a[0]/a[1] for SRLW/SRAW (replaces TODO at line 188);
  use higher_limb[1] for the MSB-carrying limb to avoid including
  sign-extended upper limbs from limb_result
- SLL: bool-check shift_u16 and tie to c_bits[4..6] for SLL/SLLW
- Add unit tests covering signed comparison and word cross-boundary shift
- Add proposals/nethermind-alu-constraint-fixes.md
@eason1981 eason1981 requested a review from succinctli May 20, 2026 04:35
@eason1981 eason1981 changed the title fix soundness and constraints for formal verification [don't-merge] fix soundness and constraints for formal verification May 20, 2026
eason added 2 commits May 20, 2026 19:32
RISC-V requires JALR to set next_pc = (rs1 + imm) & !1. Previously the
constraint only verified next_pc = rs1 + imm (no masking), and the ALU
trace event used the unmasked sum as `a` while the CPU column used the
masked emulator value — making the proof inconsistent for odd-sum targets.

Adds jalr_lsb witness column and two new constraints:
- jalr_lsb ∈ {0, 1}
- next_pc[0] * inv(2) passes u16 range check (proves next_pc is even)
- ALU ADD lookup now verifies rs1 + imm = next_pc + jalr_lsb

Trace populates jalr_lsb = (rs1 + imm) & 1 and emits the range check
event for next_pc[0] / 2.
LtWordU16Gadget::eval only verifies consistency of the `bit` witness
with the comparison limbs — it does not assert that bit = 1 (i.e. that
abs(remainder) < max(abs(c), 1) actually holds). A malicious prover
could set bit = 0 and supply an invalid remainder >= divisor while
satisfying all existing constraints.

Add the missing assertion after the gadget eval, gated on
remainder_check_multiplicity (= is_real * (1 - is_c_0)), matching SP1's
implementation.
@PetarMax
Copy link
Copy Markdown

These all appear correct to me.

eason added 9 commits May 22, 2026 09:48
For DIVW/REMW/DIVUW/REMUW, quot_msb.msb is used to sign-extend the
quotient's upper limbs (limbs 2-3) via:
  quotient[i] = quot_msb.msb * 0xFFFF  (i = 2, 3)

However, U16MSBGadget::eval was never called for quot_msb, leaving it
as a free witness. A prover could set quot_msb.msb arbitrarily and
forge the sign extension of the quotient.

Add quot_msb to msb_pairs_word so that U16MSBGadget ties quot_msb.msb
to the actual MSB of quotient[WORD_SIZE/2-1] (bit 31 of the 32-bit
quotient), matching how b_msb, c_msb, and rem_msb are handled.
… constraints

For DIVW/REMW/DIVUW/REMUW, constrain c[2] and c[3] to be the proper
sign/zero extension of c[1]. Without this, a prover can set c[2] != 0
while c[0]=c[1]=0, making is_c_0 false while the actual 32-bit divisor
is zero, bypassing the division-by-zero special case.
… constraints

Without constraining b[2]/b[3] for DIVW/REMW/DIVUW/REMUW, a malicious prover can
pick a large quotient q such that c×q overflows into b[2]/b[3], satisfying
c×q+r=b with an incorrect quotient. The upper MulGadget is disabled for word ops
so it provides no protection. This fix adds b alongside the existing c constraint.
Bug 1: the lower/higher limb decomposition of each b[i] was range-checked
but never tied back to b[i] itself. A malicious prover could pick any
range-valid (lower_limb[i], higher_limb[i]) pair independent of the actual
b[i], producing an arbitrary shift result. Fix: add the carry equation
b[i] * v_0123 = higher_limb[i] * 2^16 + lower_limb[i] * v_0123 (same
pattern as the SLL chip, constraints 21-24).

Bug 2: scalar column c (used for shift amount computation via c_bits/v_0123)
and Word column c_for_lookup (used in the CPU multiset lookup) were
disconnected -- no constraint required them to agree. A malicious prover
could shift by c while passing off c_for_lookup to the CPU, writing a
wrong result to the destination register. Fix: assert_eq(c, c_for_lookup[0]).
…_memory_access

addr_aligned was an unconstrained witness column (4×u16 limbs) passed to
eval_memory_access for the State-bus/Memory-bus Regional lookup. No constraint
bound it to addr_word or offset_bit, so a malicious prover could supply an
arbitrary aligned address, making the two buses reference different 8-byte
regions while satisfying all existing constraints.

Fix: delete addr_aligned column and compute the aligned address inline as
[addr_word[0] - offset_sum, addr_word[1], addr_word[2]], where offset_sum is
already constrained by the BitRange check in C2. This eliminates the free
witness and closes the soundness gap without adding any new constraints.
When op_a_0 = 1 (rd = x0), both mem_value_is_neg_not_x0 and
mem_value_is_pos_not_x0 collapse to zero, making all sign/zero-extension
fill constraints vacuous. op_a_val[0..3] was unconstrained, allowing a
prover to set values exceeding the u16 bound required by the Memory bus.

Fix: add op_a_0 * op_a_val[i] = 0 for i in 0..4. When op_a_0 = 1 this
pins each limb to zero; when op_a_0 = 0 the constraint is vacuous and the
existing fill constraints remain in charge.
For LB/LBU, selected_limb is split into two bytes via
selected_limb_low_byte and (selected_limb - selected_limb_low_byte)*inv_256.
Only selected_limb_low_byte was range-checked; the high byte was unconstrained.
A malicious prover could supply a fake selected_limb_low_byte that makes the
high byte an arbitrary field element (~2.13e9), breaking the u8 bound on
selected_byte and the LBU zero-extension equation.

Fix: replace slice_range_check_u8 with looking_rangecheck(U8Range, ...,
selected_limb_low_byte, high_byte, ...) to force both bytes into [0, 255],
mirroring the pattern already used by SB. Update traces accordingly.
… Word

op_b_access and op_c_access were typed as MemoryReadCols which carries five
unused fields (prev_chunk, prev_clk, compare_clk, diff_16bit_limb,
diff_8bit_limb) beyond the actual value. None of these fields were constrained
or accessed anywhere — only op_b_val()/op_c_val() were used, which returned
the inner value Word. Replace both fields with plain Word<T> and update the
populate method and helpers accordingly, removing 18 unused witness columns.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants