Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
223 lines
11 KiB
Python
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)
|