aere-research/formal-consensus/lending_liquidation_smt.py
Aere Network 6cb0140fae Republished from a clean root: the compiled artifact is gone from history, and the local line of work joins the sanitized public line
The public history carried kat/__pycache__/mlkem768_reference.cpython-314.pyc,
a compiled Python artifact embedding the operator's absolute local path. Text
secret scanners do not read compiled binaries, which is exactly how it slipped
through, and removing it from the tip would have left it reachable through the
old root commits. So this repository is republished from a single clean root.

This root also carries, from the previously unpublished line of work:
- corrected LICENSE year, LICENSING.md, VERIFY-POLICY.md, and
  CITATIONS-UNRESOLVED.md remeasured 2026-08-11 (101 paths, README aligned)
- O-018: run_consensus_verification.py ran 19 of 29 models and reported PASS;
  it now runs all 29, and computemarket_smt.py gains resolveByTimeout /
  reclaimUnsettled cases plus a negative control
- O-006: the word 'audited' removed from next to Bouncy Castle, twice, after a
  concurrent edit resurrected it
- O-014: prior art named and dated - Algorand's native falcon_verify shipped
  about ten months before AERE's precompiles; the primacy claim is withdrawn
  where it was implied
- bench/ scripts parametrized so they actually run for an outsider (the
  earlier textual sanitization left $STAGING unexpanded inside Python strings)
- AIP-2/AIP-3 errata with measured figures, spec remeasurements at 2026-08-01,
  and the spec-zk-stack retractions (owner is an operational key, not the
  Foundation; 'maximally sound' withdrawn; aggregator V1 deprecated)
The redacted bench-host environment files from the sanitized line are kept
exactly as published; the unredacted local variants are not carried.
2026-08-15 13:52:14 +03:00

223 lines
11 KiB
Python

#!/usr/bin/env python3
# -----------------------------------------------------------------------------
# lending_liquidation_smt.py
#
# SMT proof (z3) of the safety properties of AereLendingMarket.liquidate()'s
# seize / clamp math and scaled-debt bookkeeping.
#
# Contract: contracts/contracts/lending/AereLendingMarket.sol (liquidate)
#
# The liquidation path is the richest arithmetic in the market and the one that
# moves borrower collateral to a third party, so it is the highest-value money
# surface to check. We model the exact Solidity computation (integer division,
# in 18-dec USD) and prove four safety properties, each with a NEGATIVE CONTROL
# that reproduces the corresponding real bug when the guard is removed.
#
# Solidity (decimals normalised to 18, so _scaleUp/_scaleDown are identity --
# see ASSUMPTION A1):
# valueRepaid = pay * debtPrice / 1e18
# valueToSeize = valueRepaid + valueRepaid * LIQ_BONUS_BPS / 10_000
# seize = valueToSeize * 1e18 / colPrice
# if seize > collateral: # INSOLVENT CLAMP
# seize = collateral
# valueClamped = collateral * colPrice / 1e18
# payReduced = valueClamped * 10_000 / (10_000 + LIQ_BONUS_BPS)
# pay = payReduced * 1e18 / debtPrice
#
# Properties (should all hold):
# L1 no-over-seize : seize <= collateral (after clamp)
# L2 clamp not-overpay : value(pay_clamped) <= value(all collateral seized)
# i.e. the liquidator never pays more debt-value than
# the collateral they receive is worth.
# L3 bonus to liquidator (non-clamp): valueToSeize >= valueRepaid, so the
# liquidator always receives >= what they pay.
# L4 debt no-underflow : repaidScaled (clamped to debtOf) <= debtOf, so
# debtOf -= repaidScaled and totalBorrowsScaled -=
# repaidScaled never underflow.
#
# Method: PROVED = the negation is UNSAT under the guards. NEG-CTRL = the buggy
# variant's violation is SAT (real counterexample), proving the guard is load-
# bearing and the property is not vacuous.
#
# ASSUMPTIONS (bound every PROVED):
# A1. Token decimals are normalised (both 18-dec) so _scaleUp/_scaleDown are the
# identity. AERE Wave-1 debt is USDC.e (6-dec) and collateral varies; the
# scale helpers are pure power-of-ten mul/div whose rounding is monotone and
# does not change the DIRECTION of any inequality below (down-scaling only
# shrinks the liquidator's charge, strengthening L2). Modelled at 18/18 for
# tractability; the decimal helpers are checked separately for monotonicity.
# A2. Prices are positive (oracle staleness/deviation is enforced upstream by
# AereLendingOracle and is out of scope here).
# A3. This models the DESIGN math (unbounded Ints, integer division), not EVM
# 256-bit overflow; intermediate products stay < 2**256 for realistic
# token/price magnitudes.
# -----------------------------------------------------------------------------
from z3 import Int, Solver, And, Or, Not, If, sat, unsat, simplify
ONE = 10**18
results = []
def check(name, s, expect_unsat=True, kind="PROOF", show=None):
r = s.check()
if expect_unsat:
ok = (r == unsat); tag = "PROVED" if ok else "FAILED"
else:
ok = (r == sat); tag = "CEX-FOUND" if ok else "FAILED"
results.append((name, tag, ok, kind))
print(f"[{tag:9}] ({kind}) {name}: z3={r} (expected {'unsat' if expect_unsat else 'sat'})")
if r == sat and show is not None:
m = s.model()
wit = {}
for lbl, expr in show.items():
try:
wit[lbl] = simplify(m.eval(expr, model_completion=True)).as_long()
except Exception:
wit[lbl] = "?"
print(" witness:", wit)
return ok
print("### AereLendingMarket.liquidate -- seize/clamp safety (18/18 decimals)\n")
def common(s):
"""Symbolic liquidation inputs: pay(requested,<=debtReal), prices, bonus, collateral."""
pay = Int('pay')
debtP = Int('debtP')
colP = Int('colP')
bonus = Int('bonus') # LIQ_BONUS_BPS
col = Int('col') # collateralOf[borrower]
s.add(pay >= 1, debtP >= 1, colP >= 1, bonus >= 0, bonus < 5000, col >= 0)
return pay, debtP, colP, bonus, col
# ---- L1: after the clamp, seize <= collateral (no over-seize / no underflow of
# collateralOf[borrower] = collateral - seize) -----------------------------
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
seize = If(seize0 > col, col, seize0) # the Solidity clamp
s.add(Not(seize <= col))
check("L1 no-over-seize: clamped seize <= collateral", s)
# ---- L2: in the INSOLVENT clamp branch, the liquidator's actual debt payment is
# worth no more than ALL the collateral they receive. ----------------------
# pay_clamped = payReduced*1e18/debtP ; value(pay_clamped) <= valueClamped.
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
s.add(seize0 > col) # clamp branch active
valueClamped = col * colP / ONE
payReduced = valueClamped * 10_000 / (10_000 + bonus)
payC = payReduced * ONE / debtP
valuePayC = payC * debtP / ONE # value the liquidator actually pays
s.add(Not(valuePayC <= valueClamped))
check("L2 clamp not-overpay: value(pay) <= value(collateral seized)", s)
# ---- L3: in the NON-clamp branch, the liquidator can NEVER seize MORE collateral
# value than the bonus entitles (borrower-protecting direction). This is the
# safety-critical bound: seize0 = floor(valueToSeize*1e18/colP), so the value
# actually received (seize0*colP/1e18) is <= valueToSeize. Double floor-div
# only rounds DOWN, toward the protocol. ------------------------------------
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
s.add(seize0 <= col) # non-clamp branch
valueRecv = seize0 * colP / ONE # value liquidator receives
s.add(Not(valueRecv <= valueToSeize))
check("L3 non-clamp: value(collateral received) <= valueToSeize (no over-seize of value)", s)
# ---- L3-note (HONEST): the REVERSE direction (liquidator always receives >=
# value paid) does NOT hold at the wei level -- double floor-division can shave
# up to a couple wei OFF the liquidator, i.e. rounding favours the PROTOCOL /
# borrower (the safe direction). This is expected DeFi rounding, NOT a bug. We
# demonstrate the tiny dust discrepancy explicitly and label it non-safety.
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
s.add(seize0 <= col)
valueRecv = seize0 * colP / ONE
s.add(valueRepaid - valueRecv >= 1) # find any dust shortfall
check("L3-note dust: value(recv) can be < value(paid) by rounding (favours protocol, NOT a bug)",
s, expect_unsat=False, kind="ROUNDING",
show={"pay": pay, "debtP": debtP, "colP": colP, "bonus": bonus,
"valueRepaid": valueRepaid, "valueRecv": valueRecv,
"dust_shortfall": valueRepaid - valueRecv})
# ---- L4: repaidScaled (clamped to debtOf) never underflows debt bookkeeping ---
s = Solver()
payScaledRay, debtOf, ray_over_index = Int('payScaledRay'), Int('debtOf'), Int('t')
# repaidScaled = pay*RAY/borrowIndex, then clamped: if > debtOf, set = debtOf.
s.add(payScaledRay >= 0, debtOf >= 0)
repaidScaled = If(payScaledRay > debtOf, debtOf, payScaledRay)
s.add(Not(repaidScaled <= debtOf))
check("L4 debt no-underflow: clamped repaidScaled <= debtOf", s)
# ---- NEG-CTRL 1: WITHOUT the clamp, seize can exceed collateral -> the borrower's
# collateralOf = collateral - seize underflows (0.8 revert) OR, in an unchecked
# world, the liquidator seizes MORE than the borrower has (draining pooled
# collateral of OTHER borrowers). z3 finds seize0 > collateral. ------------
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
s.add(seize0 > col) # unclamped seize exceeds collateral
check("NEG-CTRL unclamped seize CAN exceed collateral (theft of pooled collateral)", s,
expect_unsat=False, kind="NEG-CTRL",
show={"pay": pay, "debtP": debtP, "colP": colP, "bonus": bonus,
"col": col, "unclamped_seize": seize0})
# ---- NEG-CTRL 2: a BUGGY clamp that INFLATES the reduced payment (multiplies by
# (10000+bonus)/10000 instead of dividing) makes the liquidator OVERPAY:
# value(pay) > value(collateral received). z3 finds it. --------------------
s = Solver()
pay, debtP, colP, bonus, col = common(s)
valueRepaid = pay * debtP / ONE
valueToSeize = valueRepaid + valueRepaid * bonus / 10_000
seize0 = valueToSeize * ONE / colP
s.add(seize0 > col, bonus >= 1) # clamp branch, real bonus
valueClamped = col * colP / ONE
payReducedBUG = valueClamped * (10_000 + bonus) / 10_000 # BUG: inflate instead of reduce
payBUG = payReducedBUG * ONE / debtP
valuePayBUG = payBUG * debtP / ONE
s.add(valueClamped >= 1)
s.add(valuePayBUG > valueClamped) # liquidator overpays
check("NEG-CTRL buggy-inflate clamp CAN make liquidator overpay (value(pay)>value(seized))", s,
expect_unsat=False, kind="NEG-CTRL",
show={"pay": pay, "debtP": debtP, "colP": colP, "bonus": bonus, "col": col,
"valueClamped": valueClamped, "valuePayBUG": valuePayBUG})
# ---- Decimal-helper monotonicity (supports ASSUMPTION A1) --------------------
# _scaleDown(x, dec<18) = x / 10**(18-dec) is monotone non-increasing and never
# increases a charge, so proving L2 at 18/18 is the WORST case for the liquidator
# on the down-scaled `pay`. Sanity: floor division is monotone.
s = Solver()
x, y, d = Int('x'), Int('y'), Int('d')
s.add(x >= 0, y >= 0, x <= y, d >= 1)
s.add(Not((x / d) <= (y / d)))
check("scaleDown monotonicity: x<=y => x/d <= y/d (floor div monotone)", s)
# ------------------------------------------------------------------------------
print("\n=== SUMMARY ===")
allok = True
for name, tag, ok, kind in results:
print(f" {tag:9} [{kind}] {name}")
allok = allok and ok
print()
print("AereLendingMarket.liquidate seize/clamp math:")
if allok:
print(" PROVED L1 (no over-seize), L2 (clamp never overpays), L3 (no over-seize of VALUE),")
print(" L4 (debt bookkeeping no-underflow), all at 18/18 decimals. The L3-note shows the")
print(" liquidator-favouring direction fails only by wei-dust that rounds toward the")
print(" protocol (the SAFE direction) -- expected DeFi rounding, not a bug. Two NEG-CTRLs")
print(" confirm the clamp and the divide-by-(10000+bonus) are load-bearing.")
else:
print(" NOT fully established (see FAILED / unexpected CEX above).")
import sys
sys.exit(0 if allok else 1)