From 3a4583d35d723ad9d5659a7e9ebf393ae4c6788b Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 9 Sep 2026 16:57:21 +0200 Subject: [PATCH 1/8] feat: import Solidity Vault for Lean proofs --- AUDIT.md | 21 ++ AXIOMS.md | 10 + Contracts/SolidityVault/Contract.lean | 7 + Contracts/SolidityVault/Proof.lean | 116 +++++++++ Contracts/SolidityVault/Spec.lean | 29 +++ README.md | 26 ++ TRUST_ASSUMPTIONS.md | 34 +++ Verity/Solidity.lean | 213 ++++++++++++++++ lakefile.lean | 27 ++ scripts/check_solidity_contract.py | 215 ++++++++++++++++ scripts/solidity_contract.py | 349 ++++++++++++++++++++++++++ 11 files changed, 1047 insertions(+) create mode 100644 Contracts/SolidityVault/Contract.lean create mode 100644 Contracts/SolidityVault/Proof.lean create mode 100644 Contracts/SolidityVault/Spec.lean create mode 100644 Verity/Solidity.lean create mode 100644 scripts/check_solidity_contract.py create mode 100644 scripts/solidity_contract.py diff --git a/AUDIT.md b/AUDIT.md index 426b5c1210..5fff4b23eb 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -5,6 +5,27 @@ reviewable. Keep it synchronized with `TRUST_ASSUMPTIONS.md` and `AXIOMS.md` whenever semantics, trusted components, generated audit artifacts, or CI boundary checks change. +## Proof-only Solidity Vault POC + +The focused suite also probes recursive AST rejection (including metadata), +contract `layout at`, registered-source symlink escape, and Lean importer digest +sensitivity. The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not +a transitive build identity. + +Evidence command: `python3 scripts/check_solidity_contract.py` (after +`lake build SolidityVault` and installation of the pinned compiler). +The focused runner builds and audits the imported execution proofs, changes +accepted deposit/getter behavior while preserving source mtime and requires +old proofs to fail, rejects unsupported source, checks unchanged artifacts/cache, +and exercises Python-importer and compiler content invalidation. Mutations occur +only in disposable copies. This is local acceptance evidence, not a new CI job, +bytecode/runtime test, or proof of translation correctness. + +The authored surface is existing `examples/solidity/Vault.sol` plus +`Contracts/SolidityVault/{Contract,Spec,Proof}.lean`; no generated model source or +bytecode is emitted. Trust and axiom scope are recorded in +`TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. + ## Current Audit State - Lean proof placeholders: 0 `sorry` in compiler/proof modules. diff --git a/AXIOMS.md b/AXIOMS.md index 0ad5bb9859..7194341dbd 100644 --- a/AXIOMS.md +++ b/AXIOMS.md @@ -2,6 +2,16 @@ This file is the authoritative registry of axioms used by Verity proof code. +## Proof-only Solidity Vault audit + +`Contracts/SolidityVault/Proof.lean` prints the axioms of every theorem. +`python3 scripts/check_solidity_contract.py` re-executes that audit and requires +coverage of all declared theorems, rejecting `sorryAx` and project axioms. +The exercised Vault proofs report only the standard Lean foundations `propext` +and `Quot.sound`; they do not depend on `solidityMappingSlot_injective`. +This does not remove the trusted Solidity frontend/translation boundary described +in `TRUST_ASSUMPTIONS.md`, or change the compiler axiom registry below. + ## Policy Axioms are exceptional. When an axiom exists, it must have: diff --git a/Contracts/SolidityVault/Contract.lean b/Contracts/SolidityVault/Contract.lean new file mode 100644 index 0000000000..c05209f20e --- /dev/null +++ b/Contracts/SolidityVault/Contract.lean @@ -0,0 +1,7 @@ +import Verity.Solidity + +namespace Contracts.SolidityVault + +solidity_contract Imported from "../../examples/solidity/Vault.sol" + +end Contracts.SolidityVault diff --git a/Contracts/SolidityVault/Proof.lean b/Contracts/SolidityVault/Proof.lean new file mode 100644 index 0000000000..276430b78a --- /dev/null +++ b/Contracts/SolidityVault/Proof.lean @@ -0,0 +1,116 @@ +import Contracts.SolidityVault.Spec + +namespace Contracts.SolidityVault +open Verity +open Verity.Stdlib.Math + +macro "reduce_import" : tactic => `(tactic| + simp_all [Spec.deposit, Spec.withdraw, Spec.balance, Spec.accountingState, + Imported.deposit, Imported.withdraw, Imported.balanceOf, + Imported.totalAssetsSlot, Imported.totalSupplySlot, Imported.shareBalancesSlot, + Contract.run, Bind.bind, Pure.pure, Verity.instMonadContract, Verity.bind, Verity.pure, msgValue, msgSender, Verity.require, + getStorage, setStorage, getMapping, setMapping, requireSomeUint, safeSub, + Nat.not_le_of_lt, Nat.not_lt_of_ge, + ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, + ContractState.writeMap, ContractState.storage, ContractState.storageMap]) + +theorem balance_meets_spec (s : ContractState) (account : Address) + (h0 : s.msgValue = 0) : Spec.balance s account := by + reduce_import + +theorem deposit_meets_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : + Spec.deposit s amount := by + reduce_import + +theorem withdraw_meets_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) + (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw s amount := by + reduce_import + +theorem deposit_nonpayable (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (Imported.deposit amount).run s = ContractResult.revert "Nonpayable" s := by + reduce_import + +/-- A late failing addition rolls back the earlier mapping and asset writes. -/ +theorem deposit_late_overflow_rollback (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = none) : + (Imported.deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by + reduce_import + +theorem withdraw_insufficient_shares (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : + (Imported.withdraw amount).run s = ContractResult.revert "InsufficientShares" s := by + reduce_import + +/-- Successful deposit changes no unrelated logical storage key. -/ +theorem deposit_frame (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) + (key : StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) + (hkm : key ≠ .map 2 s.sender) : + ((Imported.deposit amount).run s).snd.storageWords key = s.storageWords key := by + have h := deposit_meets_spec s amount h0 hs ha ht + rw [Spec.deposit] at h + rw [h] + simp [Spec.accountingState, Imported.totalAssetsSlot, Imported.totalSupplySlot, + Imported.shareBalancesSlot, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] + +theorem totalAssets_getter (s : ContractState) (h0 : s.msgValue = 0) : + Imported.totalAssets.run s = ContractResult.success (s.readSlot 0) s := by + simp [Imported.totalAssets, Imported.totalAssetsSlot, Contract.run, Verity.bind, + msgValue, Verity.require, getStorage, h0] + +theorem totalSupply_getter (s : ContractState) (h0 : s.msgValue = 0) : + Imported.totalSupply.run s = ContractResult.success (s.readSlot 1) s := by + simp [Imported.totalSupply, Imported.totalSupplySlot, Contract.run, Verity.bind, + msgValue, Verity.require, getStorage, h0] + +theorem shareBalances_getter (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : + (Imported.shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by + simp [Imported.shareBalances, Imported.shareBalancesSlot, Contract.run, Verity.bind, + msgValue, Verity.require, getMapping, h0] + +theorem withdraw_nonpayable (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (Imported.withdraw amount).run s = ContractResult.revert "Nonpayable" s := by + reduce_import + +theorem withdraw_insufficient_assets (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : (s.readSlot 0).val < amount.val) : + (Imported.withdraw amount).run s = ContractResult.revert "InsufficientAssets" s := by + reduce_import + +theorem withdraw_insufficient_supply (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : + (Imported.withdraw amount).run s = ContractResult.revert "InsufficientSupply" s := by + reduce_import + +#print axioms deposit_frame +#print axioms totalAssets_getter +#print axioms totalSupply_getter +#print axioms shareBalances_getter +#print axioms withdraw_nonpayable +#print axioms withdraw_insufficient_assets +#print axioms withdraw_insufficient_supply +#print axioms balance_meets_spec +#print axioms deposit_meets_spec +#print axioms withdraw_meets_spec +#print axioms deposit_nonpayable +#print axioms deposit_late_overflow_rollback +#print axioms withdraw_insufficient_shares + +end Contracts.SolidityVault diff --git a/Contracts/SolidityVault/Spec.lean b/Contracts/SolidityVault/Spec.lean new file mode 100644 index 0000000000..ab0b525565 --- /dev/null +++ b/Contracts/SolidityVault/Spec.lean @@ -0,0 +1,29 @@ +import Contracts.SolidityVault.Contract + +namespace Contracts.SolidityVault.Spec +open Verity + +/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ +def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := + let mapped := { s.writeMap Imported.shareBalancesSlot.slot s.sender shares with + knownAddresses := fun slot => if slot == Imported.shareBalancesSlot.slot then + (s.knownAddresses slot).insert s.sender else s.knownAddresses slot } + (mapped.writeSlot Imported.totalAssetsSlot.slot assets).writeSlot Imported.totalSupplySlot.slot supply + +def deposit (s : ContractState) (amount : Uint256) : Prop := + (Imported.deposit amount).run s = ContractResult.success () + (accountingState s (s.readMap Imported.shareBalancesSlot.slot s.sender + amount) + (s.readSlot Imported.totalAssetsSlot.slot + amount) + (s.readSlot Imported.totalSupplySlot.slot + amount)) + +def withdraw (s : ContractState) (amount : Uint256) : Prop := + (Imported.withdraw amount).run s = ContractResult.success () + (accountingState s (s.readMap Imported.shareBalancesSlot.slot s.sender - amount) + (s.readSlot Imported.totalAssetsSlot.slot - amount) + (s.readSlot Imported.totalSupplySlot.slot - amount)) + +def balance (s : ContractState) (account : Address) : Prop := + (Imported.balanceOf account).run s = + ContractResult.success (s.readMap Imported.shareBalancesSlot.slot account) s + +end Contracts.SolidityVault.Spec diff --git a/README.md b/README.md index 8fa5d83c3d..83f14d77da 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,32 @@ **Verity** is a formally verified smart contract compiler written in [Lean 4](https://lean-lang.org/). You write contracts in an embedded DSL, state what they should do, prove those properties hold, and compile to EVM bytecode. The compiler itself is proven to preserve semantics across three verified layers. Full documentation lives at [**veritylang.com**](https://veritylang.com). +## Proof-only Solidity Vault import (POC) + +`Contracts/SolidityVault/Contract.lean` imports the existing +`examples/solidity/Vault.sol` with `solidity_contract Imported from +"../../examples/solidity/Vault.sol"`. The frontend requests typed AST and storage +layout from pinned solc 0.8.33, then registers transparent, kernel-checked +`Verity.Contract` definitions directly in memory. There is no generated model +`.lean`, CompilationModel, or bytecode. `Spec.lean` and `Proof.lean` refer to those +imported executions, not the handwritten Vault implementation. + +With the Lean/package prerequisites installed, put the pinned Linux solc binary +at `.lake/solidity-import/solc` (executable; SHA-256 +`1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468`), then run: + +```sh +lake build SolidityVault +python3 scripts/check_solidity_contract.py +``` + +The acceptance script uses disposable copies for source mutations, rejection, +content-based Lake freshness, cache reuse, compiler/importer invalidation, and +an audit of every Vault theorem. It never mutates the original Solidity file. +Save Solidity, rebuild this dedicated target, then reload the Lean editor: +an already-open editor snapshot does not automatically watch `.sol` changes. +See [the trust boundary](TRUST_ASSUMPTIONS.md#proof-only-solidity-vault-import). + ## Verification status All proofs are machine-checked by the Lean kernel. CI rebuilds the proof development on every commit, and repository checks enforce that no proof is left incomplete (no `sorry`) and that the compiler proof stack carries 0 axioms (see [AXIOMS.md](AXIOMS.md)). Verification is scoped rather than total: the generic compiler theorems cover an explicitly documented fragment of the language, and the precise boundary between what is proven and what is trusted is maintained in [TRUST_ASSUMPTIONS.md](TRUST_ASSUMPTIONS.md). diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index 38b12c0d09..cefcb05948 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -2,6 +2,40 @@ This document states what Verity proves and what it still trusts. +## Proof-only Solidity Vault import + +This POC is separate from the verified compilation pipeline below. It trusts +pinned solc's typed AST/storage layout, `scripts/solidity_contract.py` validation, +and `Verity/Solidity.lean` translation to preserve Solidity meaning. Kernel +checking establishes well-typed definitions and theorems about their execution, +not a Solidity-to-Verity equivalence theorem. `sourceDigest` is provenance, not +proof of correspondence. It hashes the compiler input/output, Python frontend, +Lean translation implementation, and verified solc checksum/version. It is not +full build identity: transitive Verity semantics, Lean toolchain, and Lake build +policy are tracked separately by normal build dependencies, not this digest. +The recursive closed AST schema permits explicitly typed documentation and +compiler metadata, but rejects unknown fields/node kinds and contract `layout at`. +Canonical package containment is checked independently of source registration. +Local AST caches are trusted build artifacts: their +self-recorded hashes detect accidental corruption, not malicious replacement. + +The accepted fragment covers the existing Vault: full-width scalars, +address-to-uint256 mappings and public getters, straight-line reads/writes, +locals, checked addition/subtraction, and comparison/custom-error guards. +Unknown executable constructs are rejected; this is not general Solidity support. +Arguments/context are already typed and decoded. `Contract.run` rolls back +failed executions; errors are model strings, not verified ABI revert bytes. +The storage model uses logical keys, not a proof of physical keccak layout. +There is no deployment, calldata/dispatch, gas, external interaction, bytecode, +or full EVM equivalence claim. Initial states are arbitrary, not proven deployed +states. Arithmetic success premises restrict success theorems; separate failure +proofs cover nonpayability, insufficient balances and late-overflow rollback. + +Lake's dedicated `SolidityVault` target tracks source/compiler/Python-and-Lean-importer/build +policy bytes and normal Lean dependencies. Acceptance evidence is obtained with +`python3 scripts/check_solidity_contract.py`; stale editor snapshots are not a +current-source proof certificate. No additional project axiom is introduced. + ## Compilation Pipeline ``` diff --git a/Verity/Solidity.lean b/Verity/Solidity.lean new file mode 100644 index 0000000000..512d470662 --- /dev/null +++ b/Verity/Solidity.lean @@ -0,0 +1,213 @@ +import Lean +import Verity.Stdlib.Math + +/-! Proof-only, closed typed-AST importer. No source renderer or parse-back. -/ +open Lean Meta Elab Command + +namespace SolidityImporter + +private def field (j : Json) (key : String) : MetaM Json := + match j.getObjVal? key with + | .ok v => pure v + | .error e => throwError "{e}" +private def str (j : Json) : MetaM String := + match j.getStr? with + | .ok v => pure v + | .error e => throwError "{e}" +private def num (j : Json) : MetaM Nat := + match j.getNat? with + | .ok v => pure v + | .error e => throwError "{e}" +private def arr (j : Json) : MetaM (Array Json) := + match j.getArr? with + | .ok v => pure v + | .error e => throwError "{e}" +private def item (j : Json) (i : Nat) : MetaM Json := do + let a ← arr j + if h : i < a.size then pure a[i] else throwError "missing AST operand" +private def tag (j : Json) : MetaM String := item j 0 >>= str +private def uint := mkConst ``Verity.Core.Uint256 +private def address := mkConst ``Verity.Core.Address +private def unit := mkConst ``Unit +private def valueType (s : String) : MetaM Expr := + match s with + | "uint256" => pure uint + | "address" => pure address + | "unit" => pure unit + | _ => throwError "unsupported type {s}" +private def ret (x : Expr) : MetaM Expr := mkAppM ``Verity.pure #[x] +private def seq (m t : Expr) (k : Expr → MetaM Expr) : MetaM Expr := + withLocalDeclD `value t fun x => do + let body ← k x + mkAppM ``Verity.bind #[m, ← mkLambdaFVars #[x] body] + +private def register (name : Name) (value : Expr) : MetaM Unit := do + if (← getEnv).contains name then throwError "declaration collision: {name}" + let value ← instantiateMVars value + let type ← instantiateMVars (← inferType value) + if value.hasMVar || value.hasFVar || type.hasMVar || type.hasFVar then + throwError "unclosed imported declaration {name}" + addDecl (.defnDecl { + name := name + levelParams := [] + type := type + value := value + hints := .regular 0 + safety := .safe }) (forceExpose := true) + compileDecls #[name] (logErrors := false) + +private abbrev Locals := List (Nat × Expr) +private abbrev Slots := List (Nat × Expr) +private def lookup (xs : List (Nat × Expr)) (id : Nat) : MetaM Expr := + match xs.lookup id with + | some e => pure e + | none => throwError "unresolved declaration id {id}" + +private def checked (op : String) (a b : Expr) : MetaM Expr := do + let fn ← match op with + | "+" | "+=" => pure ``Verity.Stdlib.Math.safeAdd + | "-" | "-=" => pure ``Verity.Stdlib.Math.safeSub + | _ => throwError "unsupported arithmetic {op}" + mkAppM ``Verity.Stdlib.Math.requireSomeUint #[← mkAppM fn #[a, b], mkStrLit "Panic(0x11)"] + +private partial def eval (slots : Slots) (locals : Locals) (j : Json) + (k : Expr → MetaM Expr) : MetaM Expr := do + match ← tag j with + | "local" => k (← lookup locals (← num (← item j 1))) + | "number" => k (← mkAppM ``Verity.Core.Uint256.ofNat #[mkNatLit (← num (← item j 1))]) + | "sender" => seq (mkConst ``Verity.msgSender) address k + | "read" => + seq (← mkAppM ``Verity.getStorage #[← lookup slots (← num (← item j 1))]) uint k + | "map" => + let slot ← lookup slots (← num (← item j 1)) + eval slots locals (← item j 2) fun key => do + seq (← mkAppM ``Verity.getMapping #[slot, key]) uint k + | "+" | "-" => + let op ← tag j + eval slots locals (← item j 1) fun a => do + eval slots locals (← item j 2) fun b => do + seq (← checked op a b) uint k + | t => throwError "unsupported expression tag {t}" + +private partial def body (slots : Slots) (locals : Locals) (nodes : List Json) : MetaM Expr := do + match nodes with + | [] => ret (mkConst ``Unit.unit) + | j :: rest => + match ← tag j with + | "return" => + unless rest.isEmpty do throwError "nonterminal return" + eval slots locals (← item j 1) ret + | "let" => + let id ← num (← item j 1) + eval slots locals (← item j 2) fun v => body slots ((id, v) :: locals) rest + | "guard" => + let cond ← item j 1 + unless (← tag cond) == "<" do throwError "unsupported guard" + eval slots locals (← item cond 1) fun a => do + eval slots locals (← item cond 2) fun b => do + let av ← mkAppM ``Verity.Core.Uint256.val #[a] + let bv ← mkAppM ``Verity.Core.Uint256.val #[b] + let allowed ← mkAppM ``Nat.ble #[bv, av] + let guard ← mkAppM ``Verity.require #[allowed, mkStrLit (← str (← item j 2))] + seq guard unit fun _ => body slots locals rest + | "write" => + let lhs ← item j 1 + let op ← str (← item j 2) + let rhs ← item j 3 + let slot ← lookup slots (← num (← item lhs 1)) + let finish (key : Option Expr) : MetaM Expr := do + let write (v : Expr) : MetaM Expr := do + let m ← match key with + | none => mkAppM ``Verity.setStorage #[slot, v] + | some key => mkAppM ``Verity.setMapping #[slot, key, v] + seq m unit fun _ => body slots locals rest + if op == "=" then eval slots locals rhs write + else + let read ← match key with + | none => mkAppM ``Verity.getStorage #[slot] + | some key => mkAppM ``Verity.getMapping #[slot, key] + seq read uint fun old => eval slots locals rhs fun rhs => do + seq (← checked op old rhs) uint write + match ← tag lhs with + | "read" => finish none + | "map" => eval slots locals (← item lhs 2) fun key => finish (some key) + | _ => throwError "unsupported lvalue" + | t => throwError "unsupported statement tag {t}" + +private def nonpayable (m : Expr) : MetaM Expr := + seq (mkConst ``Verity.msgValue) uint fun value => do + let n ← mkAppM ``Verity.Core.Uint256.val #[value] + let zero ← mkAppM ``Nat.beq #[n, mkNatLit 0] + let guard ← mkAppM ``Verity.require #[zero, mkStrLit "Nonpayable"] + seq guard unit fun _ => pure m + +private partial def params (ps : List Json) (locals : Locals) + (k : Locals → MetaM Expr) : MetaM Expr := do + match ps with + | [] => k locals + | p :: ps => + let id ← num (← field p "id") + let name ← str (← field p "name") + withLocalDeclD (Name.mkSimple name) (← valueType (← str (← field p "type"))) fun x => do + mkLambdaFVars #[x] (← params ps ((id, x) :: locals) k) + +private def importModel (ns : Name) (model : Json) : MetaM Unit := do + if debug.skipKernelTC.get (← getOptions) then throwError "kernel checking must be enabled" + let fs ← arr (← field model "fields") + let functions ← arr (← field model "functions") + -- Preflight every name before registering any declaration. + let mut names := #[ns ++ `sourceDigest] + for f in fs do + names := names.push (ns ++ Name.mkSimple (← str (← field f "name"))) + if let .str s ← field f "getter" then names := names.push (ns ++ Name.mkSimple s) + for f in functions do names := names.push (ns ++ Name.mkSimple (← str (← field f "name"))) + for i in [:names.size] do + if (← getEnv).contains names[i]! || (names.extract 0 i).contains names[i]! then + throwError "declaration collision: {names[i]!}" + let mut slots := [] + for f in fs do + let mapping := (← field f "mapping") == Json.bool true + let ty ← if mapping then mkArrow address uint else pure uint + let slot ← mkAppOptM ``Verity.StorageSlot.mk #[some ty, some (mkNatLit (← num (← field f "slot")))] + let name := ns ++ Name.mkSimple (← str (← field f "name")) + register name slot + slots := (← num (← field f "id"), mkConst name) :: slots + for f in fs do + if let .str getter ← field f "getter" then + let slot ← lookup slots (← num (← field f "id")) + let value ← if (← field f "mapping") == Json.bool true then + withLocalDeclD `account address fun x => do + mkLambdaFVars #[x] (← nonpayable (← mkAppM ``Verity.getMapping #[slot, x])) + else nonpayable (← mkAppM ``Verity.getStorage #[slot]) + register (ns ++ Name.mkSimple getter) value + for f in functions do + let value ← params (← arr (← field f "params")).toList [] fun locals => do + nonpayable (← body slots locals (← arr (← field f "body")).toList) + register (ns ++ Name.mkSimple (← str (← field f "name"))) value + register (ns ++ `sourceDigest) (mkStrLit (← str (← field model "digest"))) + +syntax (name := solidityContract) "solidity_contract " ident " from " str : command + +@[command_elab solidityContract] def elabSolidityContract : CommandElab := fun stx => do + let saved ← getEnv + try + let file ← getFileName + let authored ← IO.FS.realPath file + let source := authored.parent.getD "." / stx[3].isStrLit?.get! + let mut root := authored.parent.getD "." + while !(← (root / "lakefile.lean").pathExists) do + let some p := root.parent | throwError "package root not found" + if p == root then throwError "package root not found" + root := p + let output ← IO.Process.output {cmd := "python3", args := #[(root / "scripts/solidity_contract.py").toString, source.toString]} + unless output.exitCode == 0 do throwError "Solidity import failed:\n{output.stderr}" + let model ← match Json.parse output.stdout with + | .ok j => pure j + | .error e => throwError "invalid frontend JSON: {e}" + let ns := (← getCurrNamespace) ++ stx[1].getId + liftTermElabM <| importModel ns model + catch e => + setEnv saved + throw e + +end SolidityImporter diff --git a/lakefile.lean b/lakefile.lean index f988503830..d94846e7b4 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -22,6 +22,33 @@ lean_lib «Verity» where .one `Verity.Proofs.LoopSimulationResultAware ] +input_file vaultSolidity where + path := "examples/solidity/Vault.sol" + text := false + +input_file vaultFrontend where + path := "scripts/solidity_contract.py" + text := false + +input_file vaultLeanImporter where + path := "Verity/Solidity.lean" + text := false + +input_file vaultSolc where + path := ".lake/solidity-import/solc" + text := false + +input_file vaultBuildPolicy where + path := "lakefile.lean" + text := false + +lean_lib «SolidityFrontend» where + globs := #[.one `Verity.Solidity] + +lean_lib «SolidityVault» where + globs := #[.submodules `Contracts.SolidityVault] + needs := #[vaultSolidity, vaultFrontend, vaultLeanImporter, vaultSolc, vaultBuildPolicy] + lean_lib «Contracts» where globs := #[ .one `Contracts, diff --git a/scripts/check_solidity_contract.py b/scripts/check_solidity_contract.py new file mode 100644 index 0000000000..b597e661d6 --- /dev/null +++ b/scripts/check_solidity_contract.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Focused proof-only Vault acceptance checks; all mutations are in a disposable copy. + +Prerequisite: lake build SolidityVault and the pinned .lake/solidity-import/solc. +Runs no bytecode compiler and writes no generated model Lean source. +""" +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +ENV = dict(os.environ, PATH=f"{Path.home()}/.elan/bin:{Path.home()}/.local/bin:" + os.environ['PATH']) + + +def check(ok, message): + if not ok: + raise AssertionError(message) + print('PASS ' + message, flush=True) + + +def run(root, args, success=True, contains=None): + p = subprocess.run(args, cwd=root, env=ENV, text=True, capture_output=True, timeout=180) + out = p.stdout + p.stderr + if (p.returncode == 0) != success or (contains and contains not in out) or 'PANIC' in out: + raise AssertionError(f'{args}: exit {p.returncode}\n{out}') + return out + + +def main(): + with tempfile.TemporaryDirectory(prefix='verity-vault-check-') as directory: + root = Path(directory) + # Copy mutable build outputs (never hardlink); share only prebuilt dependencies. + for name in ('Verity', 'Contracts/SolidityVault', 'scripts', 'examples/solidity'): + shutil.copytree(ROOT / name, root / name) + for name in ('lakefile.lean', 'lake-manifest.json', 'lean-toolchain'): + shutil.copy2(ROOT / name, root / name) + for name in ('build', 'solidity-import'): + shutil.copytree(ROOT / '.lake' / name, root / '.lake' / name) + (root / '.lake/packages').symlink_to(ROOT / '.lake/packages', target_is_directory=True) + source = root / 'examples/solidity/Vault.sol' + original = source.read_bytes() + stamp = source.stat() + frontend = root / 'scripts/solidity_contract.py' + frontend_original = frontend.read_bytes() + compiler = root / '.lake/solidity-import/solc' + compiler_original = compiler.read_bytes() + lean_sources = set(root.rglob('*.lean')) + def edit(data): + source.write_bytes(data) + os.utime(source, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) + def build(success=True, contains=None): + return run(root, ['lake', 'build', 'SolidityVault'], success, contains) + def model(success=True, contains=None): + return run(root, ['python3', str(frontend), str(source)], success, contains) + def artifacts(): + return {str(p.relative_to(root)): (p.stat().st_mtime_ns, hashlib.sha256(p.read_bytes()).hexdigest()) + for p in (root / '.lake/build/lib/lean/Contracts/SolidityVault').glob('*.olean')} + def caches(): + return {p.name: (p.stat().st_mtime_ns, p.read_bytes()) for p in compiler.parent.glob('*.json')} + build() + check(True, 'baseline lake build SolidityVault') + proof = root / 'Contracts/SolidityVault/Proof.lean' + theorem_names = re.findall(r'^theorem\s+(\w+)', proof.read_text(), re.M) + audit = run(root, ['lake', 'env', 'lean', str(proof)]) + entries = re.findall(r"'Contracts.SolidityVault.(\w+)' depends on axioms: \[([^\]]*)\]", audit) + check(set(theorem_names) == {name for name, _ in entries}, 'every theorem appears in actual #print axioms output') + axioms = {a.strip() for _, values in entries for a in values.split(',') if a.strip()} + check(axioms <= {'propext', 'Quot.sound', 'Classical.choice'}, 'no project axioms or sorryAx: ' + ', '.join(sorted(axioms))) + before, cached = artifacts(), caches() + first = model() + build() + check(before == artifacts(), 'unchanged Lake rebuild reuses all Vault oleans') + check(first == model() and cached == caches(), 'unchanged frontend reuses identical AST cache without rewriting') + cache_probe = """import runpy, subprocess, sys +original = subprocess.Popen +class Guard(subprocess.Popen): + def __init__(self, args, *a, **kw): + assert '--standard-json' not in args, 'unexpected solc compilation on cache hit' + super().__init__(args, *a, **kw) +subprocess.Popen = Guard +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name='__main__') +""" + check(run(root, ['python3', '-c', cache_probe, str(frontend), str(source)]) == first, + 'cached frontend does not invoke solc --standard-json (subprocess guard)') + # Mutate compiler AST only in memory; exercise the same schema gate as main. + ast_probe = """import copy, importlib.util, json, pathlib, sys +spec = importlib.util.spec_from_file_location('frontend', sys.argv[1]) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +records = [json.loads(p.read_text()) for p in pathlib.Path(sys.argv[2]).glob('*.json')] +ast = next(r['output']['sources'][m.SOURCE]['ast'] for r in records + if r['output']['sources'][m.SOURCE]['ast']['nodes'][-1]['name'] == 'Vault') +def need(ok, n, why): + if not ok: raise ValueError(why) +m.validate_ast(ast, need) # existing structured documentation is legitimate +for mutation in ('unknown child', 'altered block', 'metadata child'): + changed = copy.deepcopy(ast) + contract = next(n for n in changed['nodes'] if n['nodeType'] == 'ContractDefinition') + f = next(n for n in contract['nodes'] if n['nodeType'] == 'FunctionDefinition') + if mutation == 'unknown child': f['body']['unexpectedExecutable'] = copy.deepcopy(f['body']['statements'][0]) + elif mutation == 'altered block': f['body']['nodeType'] = 'UncheckedBlock' + else: contract['documentation']['unexpectedExecutable'] = copy.deepcopy(f['body']) + try: m.validate_ast(changed, need) + except ValueError: print('rejected ' + mutation) + else: raise AssertionError('accepted ' + mutation) +""" + probe = run(root, ['python3', '-c', ast_probe, str(frontend), str(compiler.parent)]) + check(all('rejected ' + name in probe for name in ('unknown child', 'altered block', 'metadata child')), + 'closed recursive AST schema rejects unknown executable children and altered body kind; accepts documentation') + # Even the registered source must remain inside the canonical package root. + with tempfile.TemporaryDirectory(prefix='verity-vault-outside-') as outside: + escaped = Path(outside) / 'Vault.sol' + escaped.write_bytes(original) + source.unlink() + source.symlink_to(escaped) + try: + model(False, 'source outside package') + check(True, 'registered-source symlink escape rejected in temporary sandbox') + finally: + source.unlink() + edit(original) + baseline_model = json.loads(first) + edit(original.replace(b'assets', b'depositAmount')) + model() + build() + check(True, 'parameter rename and references preserve existing proofs') + edit(original) + build() + for name, old, new in ( + ('deposit behavior', b'totalSupply += assets;', b'totalSupply = assets;'), + ('getter behavior', b'return shareBalances[account];', b'return totalAssets;'), + ): + check(original.count(old) == 1, name + ' mutation has one source target') + before = artifacts() + edit(original.replace(old, new)) + changed_model = json.loads(model()) + check(changed_model['functions'] != baseline_model['functions'], name + ' changes accepted AST behavior') + out = build(False, 'Contracts.SolidityVault.Proof') + check('unsolved goals' in out or 'Type mismatch' in out or 'type mismatch' in out, + name + ' preserved-mtime source edit rebuilds and breaks existing proof') + check(before != artifacts(), name + ' refreshes dependent oleans') + edit(original) + build() + for name, old, new, diagnostic in ( + ('contract layout at', b'contract Vault {', b'contract Vault layout at 100 {', 'layout at'), + ('initializer', b'uint256 public totalAssets;', b'uint256 public totalAssets = 1;', 'initializer'), + ('unchecked block', b'totalAssets += assets;', b'unchecked { totalAssets += assets; }', 'UncheckedBlock'), + ('loop', b'totalAssets += assets;', b'while (assets < totalAssets) { totalAssets += assets; }', 'WhileStatement'), + ('second contract', b'contract Vault {', b'contract Other {}\ncontract Vault {', 'exactly one'), + ('multiplication', b'totalAssets += assets;', b'totalAssets = totalAssets * assets;', 'unsupported binary'), + ): + edit(original.replace(old, new)) + output = model(False, diagnostic) + check(re.search(r'examples/solidity/Vault.sol:\d+:\d+:', output) is not None, + name + ' rejected with source location') + # Exercise an import failure through Lake as well as the frontend. + build(False, 'unsupported binary') + check(True, 'unsupported source cannot reuse prior successful Lake artifact') + edit(original) + build() + before = artifacts() + frontend.write_bytes(frontend_original + b'\n# acceptance invalidation probe\n') + build() + check(before != artifacts(), 'Python importer content change invalidates Vault oleans') + check(json.loads(model())['digest'] != baseline_model['digest'], 'importer change updates sourceDigest') + frontend.write_bytes(frontend_original) + build() + lean_importer = root / 'Verity/Solidity.lean' + lean_original = lean_importer.read_bytes() + try: + lean_importer.write_bytes(lean_original + b'\n-- acceptance translation identity probe\n') + check(json.loads(model())['digest'] != baseline_model['digest'], + 'Lean translation implementation change updates sourceDigest') + before = artifacts() + build() + check(before != artifacts(), 'Lean importer change invalidates Vault oleans') + finally: + lean_importer.write_bytes(lean_original) + build() + before_cache = caches() + frontend.write_bytes(frontend_original.replace(b"optimizer={'enabled': False}", b"optimizer={'enabled': True}")) + build() + check(before_cache.keys() != caches().keys(), 'compiler settings change creates a distinct AST cache entry') + frontend.write_bytes(frontend_original) + build() + policy = root / 'lakefile.lean' + policy_original = policy.read_bytes() + before = artifacts() + policy.write_bytes(policy_original + b'\n-- acceptance build-policy probe\n') + build() + check(before != artifacts(), 'build-policy content change invalidates Vault oleans') + policy.write_bytes(policy_original) + build() + # Appended data preserves executable format but violates the pinned binary hash. + compiler_stamp = compiler.stat() + compiler.write_bytes(compiler_original + b'\nacceptance-check\n') + os.utime(compiler, ns=(compiler_stamp.st_atime_ns, compiler_stamp.st_mtime_ns)) + build(False, 'compiler checksum mismatch') + check(True, 'compiler content change invalidates Lake and fails closed') + compiler.write_bytes(compiler_original) + build() + check(set(root.rglob('*.lean')) == lean_sources, 'no generated model .lean files') + check(source.read_bytes() == original and frontend.read_bytes() == frontend_original, + 'temporary source and importer restored; final baseline build passes') + print(f'PASS all Vault acceptance checks ({len(theorem_names)} audited theorems)', flush=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/solidity_contract.py b/scripts/solidity_contract.py new file mode 100644 index 0000000000..592eaf2bac --- /dev/null +++ b/scripts/solidity_contract.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Closed Vault-feature typed-AST frontend. Emits JSON, never Lean source.""" +import hashlib +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = 'examples/solidity/Vault.sol' +PIN = '1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468' +SETTINGS = dict(optimizer={'enabled': False}, viaIR=False, evmVersion='cancun', + remappings=[], outputSelection={'*': {'': ['ast'], '*': ['storageLayout']}}) + +def digest(b): + return hashlib.sha256(b).hexdigest() + +# Closed schema for pinned solc's accepted AST. Metadata is explicitly typed, +# never a catch-all escape hatch for unknown executable children. +NODE_FIELDS = { + 'SourceUnit': 'absolutePath exportedSymbols license nodes', + 'PragmaDirective': 'literals', + 'ContractDefinition': 'abstract baseContracts canonicalName contractDependencies contractKind documentation fullyImplemented linearizedBaseContracts name nameLocation nodes scope usedErrors usedEvents storageLayout', + 'StructuredDocumentation': 'text', + 'VariableDeclaration': 'constant functionSelector mutability name nameLocation scope stateVariable storageLocation typeDescriptions typeName visibility value documentation overrides indexed', + 'ElementaryTypeName': 'name stateMutability typeDescriptions', + 'Mapping': 'keyName keyNameLocation keyType typeDescriptions valueName valueNameLocation valueType', + 'ErrorDefinition': 'errorSelector name nameLocation parameters documentation', + 'FunctionDefinition': 'body functionSelector implemented kind modifiers name nameLocation parameters returnParameters scope stateMutability virtual visibility documentation overrides baseFunctions', + 'ParameterList': 'parameters', + 'Block': 'statements documentation', + 'ExpressionStatement': 'expression', + 'Assignment': 'leftHandSide operator rightHandSide', + 'BinaryOperation': 'commonType leftExpression operator rightExpression function', + 'Identifier': 'argumentTypes name overloadedDeclarations referencedDeclaration', + 'MemberAccess': 'expression memberLocation memberName referencedDeclaration', + 'IndexAccess': 'baseExpression indexExpression', + 'Literal': 'hexValue kind subdenomination value', + 'FunctionCall': 'arguments expression kind nameLocations names tryCall', + 'VariableDeclarationStatement': 'assignments declarations initialValue', + 'IfStatement': 'condition trueBody falseBody', + 'RevertStatement': 'errorCall', + 'Return': 'expression functionReturnParameters', +} +EXPRESSION_NODES = {'Assignment', 'BinaryOperation', 'Identifier', 'MemberAccess', + 'IndexAccess', 'Literal', 'FunctionCall'} +CHILDREN = {'nodes', 'baseContracts', 'parameters', 'returnParameters', 'body', + 'statements', 'typeName', 'keyType', 'valueType', 'value', 'modifiers', + 'overrides', 'storageLayout', 'leftHandSide', 'rightHandSide', + 'leftExpression', 'rightExpression', 'expression', 'baseExpression', + 'indexExpression', 'arguments', 'declarations', 'initialValue', + 'condition', 'trueBody', 'falseBody', 'errorCall'} +STRING_FIELDS = set('absolutePath license canonicalName contractKind name nameLocation text functionSelector mutability storageLocation visibility stateMutability keyName keyNameLocation valueName valueNameLocation errorSelector kind operator memberLocation memberName hexValue subdenomination'.split()) +BOOL_FIELDS = set('abstract fullyImplemented constant stateVariable indexed implemented virtual isConstant isLValue isPure lValueRequested tryCall'.split()) +INT_FIELDS = {'scope', 'referencedDeclaration', 'functionReturnParameters', 'function'} +INT_LIST_FIELDS = {'contractDependencies', 'linearizedBaseContracts', 'usedErrors', 'usedEvents', 'baseFunctions', 'overloadedDeclarations', 'assignments'} +STRING_LIST_FIELDS = {'literals', 'names', 'nameLocations'} + + +def validate_ast(ast, need): + def types(value, owner): + need(isinstance(value, dict) and set(value) <= {'typeIdentifier', 'typeString'} + and all(isinstance(v, str) for v in value.values()), owner, 'invalid type metadata') + def visit(n): + need(isinstance(n, dict) and n.get('nodeType') in NODE_FIELDS, n if isinstance(n, dict) else ast, 'unsupported AST node') + k = n['nodeType'] + if k == 'ContractDefinition': + need(n.get('storageLayout') is None, n, 'contract layout at specifier unsupported') + allowed = set(NODE_FIELDS[k].split()) | {'id', 'src', 'nodeType'} + if k in EXPRESSION_NODES: + allowed |= {'isConstant', 'isLValue', 'isPure', 'lValueRequested', 'typeDescriptions'} + need(not (set(n) - allowed), n, 'unexpected AST fields: ' + ', '.join(sorted(set(n) - allowed))) + need(type(n.get('id')) is int and isinstance(n.get('src'), str), n, 'missing AST identity/span') + for key, v in n.items(): + if key in {'id', 'src', 'nodeType'}: + continue + if key == 'documentation': + if isinstance(v, dict): + need(v.get('nodeType') == 'StructuredDocumentation', n, 'invalid documentation') + visit(v) + else: + need(v is None or isinstance(v, str), n, 'invalid documentation') + elif key in {'typeDescriptions', 'commonType'}: + types(v, n) + elif key == 'argumentTypes': + need(isinstance(v, list), n, 'invalid argument type metadata') + for t in v: + types(t, n) + elif key == 'exportedSymbols': + need(isinstance(v, dict) and all(isinstance(ids, list) and all(type(i) is int for i in ids) for ids in v.values()), n, 'invalid symbol metadata') + elif key == 'value' and k == 'Literal': + need(isinstance(v, str), n, 'invalid literal value') + elif key in CHILDREN: + if v is not None: + if isinstance(v, list): + for child in v: + if child is not None: + visit(child) + else: + visit(v) + elif key in STRING_FIELDS: + need(isinstance(v, str) or (key == 'subdenomination' and v is None), n, 'invalid string metadata: ' + key) + elif key in BOOL_FIELDS: + need(type(v) is bool, n, 'invalid boolean metadata: ' + key) + elif key in INT_FIELDS: + need(type(v) is int, n, 'invalid declaration metadata: ' + key) + elif key in INT_LIST_FIELDS: + need(isinstance(v, list) and all(type(i) is int or (key == 'assignments' and i is None) for i in v), n, 'invalid declaration list: ' + key) + elif key in STRING_LIST_FIELDS: + need(isinstance(v, list) and all(isinstance(i, str) for i in v), n, 'invalid string list: ' + key) + else: + need(False, n, 'unclassified AST field: ' + key) + # Structural positions are closed as well as node field names. In + # particular a known node kind in the wrong position is not metadata. + lists = {'nodes', 'baseContracts', 'statements', 'modifiers', 'arguments', 'declarations'} + if k == 'ParameterList': + lists.add('parameters') + nullable = {'value', 'overrides', 'storageLayout', 'falseBody'} + required = { + 'SourceUnit': ('nodes',), 'ContractDefinition': ('nodes', 'baseContracts'), + 'FunctionDefinition': ('body', 'parameters', 'returnParameters', 'modifiers'), + 'ParameterList': ('parameters',), 'Block': ('statements',), + 'VariableDeclaration': ('typeName',), 'Mapping': ('keyType', 'valueType'), + 'ExpressionStatement': ('expression',), 'Assignment': ('leftHandSide', 'rightHandSide'), + 'BinaryOperation': ('leftExpression', 'rightExpression'), + 'MemberAccess': ('expression',), 'IndexAccess': ('baseExpression', 'indexExpression'), + 'FunctionCall': ('expression', 'arguments'), + 'VariableDeclarationStatement': ('declarations', 'initialValue'), + 'IfStatement': ('condition', 'trueBody'), 'RevertStatement': ('errorCall',), + 'Return': ('expression',), 'ErrorDefinition': ('parameters',), + } + need(all(key in n for key in required.get(k, ())), n, 'missing required AST children') + for key in CHILDREN & n.keys(): + if key == 'value' and k == 'Literal': + continue + v = n[key] + if key in lists: + need(isinstance(v, list) and all(isinstance(child, dict) for child in v), n, 'invalid AST child list: ' + key) + else: + need(isinstance(v, dict) or (key in nullable and v is None), n, 'invalid AST child: ' + key) + expected = {'typeName': {'ElementaryTypeName', 'Mapping'}, + 'keyType': {'ElementaryTypeName'}, 'valueType': {'ElementaryTypeName'}, + 'errorCall': {'FunctionCall'}, 'trueBody': {'Block'}} + for key, kinds in expected.items(): + if key in n: + need(n[key].get('nodeType') in kinds, n, 'unexpected child kind: ' + key) + if k == 'ParameterList': + need(all(p['nodeType'] == 'VariableDeclaration' for p in n['parameters']), n, 'invalid parameter declaration') + if k == 'VariableDeclarationStatement': + need(all(p['nodeType'] == 'VariableDeclaration' for p in n['declarations']), n, 'invalid local declaration') + if k == 'VariableDeclaration': + need(n.get('value') is None and n.get('overrides') is None, n, 'initializer/override unsupported') + if k == 'BinaryOperation': + need(n.get('function') is None, n, 'user-defined operator unsupported') + if k == 'FunctionCall': + need(n['kind'] == 'functionCall' and not n['tryCall'] and not n['names'], n, 'unsupported call surface') + if k == 'FunctionDefinition': + need(isinstance(n.get('body'), dict) and n['body'].get('nodeType') == 'Block', n, 'function body must be Block') + for key in ('parameters', 'returnParameters'): + if key in n and k != 'ParameterList': + need(isinstance(n[key], dict) and n[key].get('nodeType') == 'ParameterList', n, key + ' must be ParameterList') + if k == 'Block': + need(isinstance(n.get('statements'), list), n, 'Block requires statements') + need(isinstance(ast, dict) and ast.get('nodeType') == 'SourceUnit', ast, 'root must be SourceUnit') + visit(ast) + + +def main(): + source = pathlib.Path(sys.argv[1]).resolve(strict=True) + if not source.is_relative_to(ROOT.resolve(strict=True)): + raise ValueError('source outside package') + if source != (ROOT / SOURCE).resolve(strict=True): + raise ValueError('unregistered source or source outside package') + raw = source.read_bytes() + binary = ROOT / '.lake/solidity-import/solc' + if digest(binary.read_bytes()) != PIN: + raise ValueError('compiler checksum mismatch') + version = subprocess.check_output([str(binary), '--version']).decode() + if '0.8.33+commit.64118f21.' not in version: + raise ValueError('compiler version mismatch') + inp = dict(language='Solidity', sources={SOURCE: {'content': raw.decode()}}, settings=SETTINGS) + encoded = json.dumps(inp, sort_keys=True).encode() + key = digest(encoded + PIN.encode()) + cache = binary.parent / (key + '.json') + if cache.exists(): + record = json.loads(cache.read_text()) + out = record['output'] + if record['key'] != key or record['digest'] != digest(json.dumps(out, sort_keys=True).encode()): + raise ValueError('corrupt AST cache') + else: + p = subprocess.run([str(binary), '--standard-json', '--no-import-callback'], + input=encoded, capture_output=True, check=True) + out = json.loads(p.stdout) + if any(e['severity'] == 'error' for e in out.get('errors', [])): + raise ValueError('\n'.join(e['formattedMessage'] for e in out['errors'])) + record = dict(key=key, output=out, digest=digest(json.dumps(out, sort_keys=True).encode())) + tmp = cache.with_suffix('.tmp') + tmp.write_text(json.dumps(record, sort_keys=True)) + tmp.replace(cache) + if set(out['sources']) != {SOURCE}: + raise ValueError('unexpected compiler sources') + ast = out['sources'][SOURCE]['ast'] + def fail(n, why): + start, size, sid = map(int, n['src'].split(':')) + if sid != out['sources'][SOURCE]['id']: + raise ValueError('unexpected source id') + prefix = raw[:start] + line = prefix.count(b'\n') + 1 + column = len(prefix.rsplit(b'\n', 1)[-1]) + 1 + excerpt = raw[start:start + min(size, 100)].decode(errors='replace') + raise ValueError(f'{SOURCE}:{line}:{column}: {n["nodeType"]}: {why}\n{excerpt}') + def need(ok, n, why): + if not ok: + fail(n, why) + def ident(n): + name = n['name'] + need(re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*', name) and name != 'sourceDigest', n, 'unsupported/reserved name') + return name + validate_ast(ast, need) + contracts = [] + for n in ast['nodes']: + if n['nodeType'] == 'PragmaDirective': + need(n['literals'][0] == 'solidity', n, 'unsupported pragma') + elif n['nodeType'] == 'ContractDefinition': + contracts.append(n) + else: + fail(n, 'unsupported source declaration') + need(len(contracts) == 1, ast, 'exactly one concrete contract required') + c = contracts[0] + need(c['contractKind'] == 'contract' and not c['abstract'] and not c['baseContracts'], c, 'inheritance/abstract contract unsupported') + layout = out['contracts'][SOURCE][c['name']]['storageLayout'] + entries = {x['astId']: x for x in layout['storage']} + fields, funcs, errors = {}, [], {} + for n in c['nodes']: + kind = n['nodeType'] + if kind == 'VariableDeclaration': + name = ident(n) + need(n['stateVariable'] and not n['constant'] and n['mutability'] == 'mutable' and n.get('value') is None and n['storageLocation'] == 'default', n, 'initializer/constant/transient field unsupported') + typ = n['typeDescriptions']['typeString'] + need(typ in ('uint256', 'mapping(address => uint256)'), n, 'unsupported storage type') + e = entries.get(n['id']) + need(e is not None and e['offset'] == 0, n, 'missing/packed layout') + t = layout['types'][e['type']] + need(t['numberOfBytes'] == '32', n, 'nonword layout') + if typ == 'uint256': + need(t['encoding'] == 'inplace' and t['label'] == typ, n, 'bad scalar layout') + else: + need(t['encoding'] == 'mapping' and layout['types'][t['key']]['label'] == 'address' and layout['types'][t['value']]['label'] == 'uint256', n, 'bad mapping layout') + fields[n['id']] = dict(id=n['id'], name=name + 'Slot', getter=name if n['visibility'] == 'public' else None, slot=int(e['slot']), mapping=typ.startswith('mapping')) + elif kind == 'ErrorDefinition': + need(not n['parameters']['parameters'], n, 'only zero-argument custom errors') + errors[n['id']] = ident(n) + elif kind == 'FunctionDefinition': + funcs.append(n) + else: + fail(n, 'unsupported contract declaration') + need(set(entries) == set(fields), c, 'unaccounted layout field') + def ty(n): + t = n['typeDescriptions']['typeString'] + need(t in ('uint256', 'address'), n, 'unsupported value type') + return t + def expr(n, scope): + k = n['nodeType'] + if k == 'Identifier': + rid = n['referencedDeclaration'] + if rid in scope: + need(ty(n) == scope[rid], n, 'reference type mismatch') + return ['local', rid] + need(rid in fields and not fields[rid]['mapping'], n, 'unresolved/non-scalar reference') + return ['read', rid] + if k == 'MemberAccess': + b = n['expression'] + need(n['memberName'] == 'sender' and b['nodeType'] == 'Identifier' and b['name'] == 'msg' and b['referencedDeclaration'] < 0 and ty(n) == 'address', n, 'only builtin msg.sender supported') + return ['sender'] + if k == 'IndexAccess': + b = n['baseExpression'] + need(b['nodeType'] == 'Identifier' and b['referencedDeclaration'] in fields and fields[b['referencedDeclaration']]['mapping'], n, 'unsupported index base') + need(ty(n['indexExpression']) == 'address' and ty(n) == 'uint256', n, 'bad mapping index/value type') + return ['map', b['referencedDeclaration'], expr(n['indexExpression'], scope)] + if k == 'Literal': + need(n['kind'] == 'number' and not n.get('subdenomination') and re.fullmatch('[0-9]+', n['value']) and int(n['value']) < 2**256, n, 'unsupported literal') + return ['number', int(n['value'])] + if k == 'BinaryOperation': + need(n['operator'] in ('+', '-', '<') and ty(n['leftExpression']) == 'uint256' and ty(n['rightExpression']) == 'uint256', n, 'unsupported binary operation/types') + return [n['operator'], expr(n['leftExpression'], scope), expr(n['rightExpression'], scope)] + fail(n, 'unsupported expression') + def statements(nodes, scope, returns): + result = [] + for i, n in enumerate(nodes): + k = n['nodeType'] + if k == 'ExpressionStatement': + a = n['expression'] + need(a['nodeType'] == 'Assignment' and a['operator'] in ('=', '+=', '-='), n, 'unsupported expression statement') + lhs = expr(a['leftHandSide'], scope) + need(lhs[0] in ('read', 'map'), a, 'only storage assignment supported') + rhs = expr(a['rightHandSide'], scope) + result.append(['write', lhs, a['operator'], rhs]) + elif k == 'VariableDeclarationStatement': + ds = n['declarations'] + need(len(ds) == 1 and ds[0] is not None and n['initialValue'] is not None, n, 'unsupported locals') + d = ds[0] + need(ty(d) == 'uint256', d, 'only uint256 locals') + value = expr(n['initialValue'], scope) + scope = dict(scope, **{}) + scope[d['id']] = ty(d) + result.append(['let', d['id'], value]) + elif k == 'IfStatement': + need(n.get('falseBody') is None and n['trueBody']['nodeType'] == 'Block', n, 'only if/revert guard supported') + body = n['trueBody']['statements'] + need(len(body) == 1 and body[0]['nodeType'] == 'RevertStatement', n, 'only if/revert guard supported') + call = body[0]['errorCall'] + callee = call['expression'] + need(callee['nodeType'] == 'Identifier' and callee['referencedDeclaration'] in errors and not call['arguments'], n, 'unsupported revert') + condition = expr(n['condition'], scope) + need(condition[0] == '<', n, 'only uint256 comparison guard supported') + result.append(['guard', condition, errors[callee['referencedDeclaration']]]) + elif k == 'Return': + need(i == len(nodes)-1 and returns == 'uint256' and n['expression'] is not None, n, 'only terminal scalar return') + result.append(['return', expr(n['expression'], scope)]) + else: + fail(n, 'unsupported statement') + if returns != 'unit': + need(result and result[-1][0] == 'return', c, 'missing terminal return') + return result + output = [] + for f in funcs: + name = ident(f) + need(f['kind'] == 'function' and f['implemented'] and not f['modifiers'] and not f['virtual'] and not f.get('overrides') and f['visibility'] in ('external', 'public') and f['stateMutability'] in ('nonpayable', 'view'), f, 'unsupported function surface') + ps = f['parameters']['parameters'] + rs = f['returnParameters']['parameters'] + need(len(ps) <= 1 and len(rs) <= 1 and all(not r['name'] and ty(r) == 'uint256' for r in rs), f, 'unsupported signature') + params = [dict(id=p['id'], name=ident(p), type=ty(p)) for p in ps] + returns = 'uint256' if rs else 'unit' + output.append(dict(name=name, params=params, returns=returns, body=statements(f['body']['statements'], {p['id']: p['type'] for p in params}, returns))) + names = [f['name'] for f in fields.values()] + [f['getter'] for f in fields.values() if f['getter']] + [f['name'] for f in output] + ['sourceDigest'] + need(len(names) == len(set(names)), c, 'overload/generated name collision') + print(json.dumps(dict(fields=list(fields.values()), functions=output, + digest=digest(json.dumps(dict(input=inp, output=out, + pythonImporter=digest(pathlib.Path(__file__).read_bytes()), + leanImporter=digest((ROOT / 'Verity/Solidity.lean').read_bytes()), + compilerSha256=PIN, compilerVersion=version), sort_keys=True).encode())))) + +if __name__ == '__main__': + try: + main() + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as e: + print(str(e), file=sys.stderr) + sys.exit(1) From 3074797c52132819562a43558310d1e9985d9fd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 16:58:55 +0200 Subject: [PATCH 2/8] chore: auto-refresh derived artifacts --- artifacts/verification_status.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/artifacts/verification_status.json b/artifacts/verification_status.json index 3ed6aeae48..f376e81d4b 100644 --- a/artifacts/verification_status.json +++ b/artifacts/verification_status.json @@ -1,7 +1,7 @@ { "codebase": { "core_lines": 2040, - "example_contracts": 18 + "example_contracts": 19 }, "proofs": { "axioms": 1, From 28302023fdda8cd53c744d4be993a9ec48167f67 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Wed, 9 Sep 2026 18:21:33 +0200 Subject: [PATCH 3/8] fix: share Vault specs across implementations --- AUDIT.md | 8 +- AXIOMS.md | 6 +- Contracts/SolidityVault/Contract.lean | 7 -- Contracts/SolidityVault/Proof.lean | 116 ------------------ Contracts/SolidityVault/Spec.lean | 29 ----- Contracts/Vault/Implementations.lean | 58 +++++++++ Contracts/Vault/Proofs/Execution.lean | 164 ++++++++++++++++++++++++++ Contracts/Vault/README.md | 50 ++++++++ Contracts/Vault/Solidity.lean | 7 ++ Contracts/Vault/Spec.lean | 24 ++++ Contracts/Vault/Vault.lean | 21 ++-- README.md | 9 +- TRUST_ASSUMPTIONS.md | 11 ++ lakefile.lean | 9 +- scripts/check_solidity_contract.py | 26 +++- scripts/solidity_contract.py | 3 +- 16 files changed, 371 insertions(+), 177 deletions(-) delete mode 100644 Contracts/SolidityVault/Contract.lean delete mode 100644 Contracts/SolidityVault/Proof.lean delete mode 100644 Contracts/SolidityVault/Spec.lean create mode 100644 Contracts/Vault/Implementations.lean create mode 100644 Contracts/Vault/Proofs/Execution.lean create mode 100644 Contracts/Vault/README.md create mode 100644 Contracts/Vault/Solidity.lean diff --git a/AUDIT.md b/AUDIT.md index 5fff4b23eb..0abe1e64bb 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -22,8 +22,12 @@ only in disposable copies. This is local acceptance evidence, not a new CI job, bytecode/runtime test, or proof of translation correctness. The authored surface is existing `examples/solidity/Vault.sol` plus -`Contracts/SolidityVault/{Contract,Spec,Proof}.lean`; no generated model source or -bytecode is emitted. Trust and axiom scope are recorded in +`Contracts/Vault/{Solidity,Implementations,Spec}.lean` and `Proofs/Execution.lean`. +The same theorem statements and proofs are checked for `.verity` and `.solidity`, +including the pre-existing deposit/withdrawal specs. Native Vault now uses matching +typed custom errors and deposit write order. `Implementations.lean` makes the +nonpayable entry boundary explicit: native bodies get the compiler dispatch rule; +imported functions already include it. No generated model source or bytecode is emitted. Trust and axiom scope are recorded in `TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. ## Current Audit State diff --git a/AXIOMS.md b/AXIOMS.md index 7194341dbd..2c1c8ad0e1 100644 --- a/AXIOMS.md +++ b/AXIOMS.md @@ -4,11 +4,11 @@ This file is the authoritative registry of axioms used by Verity proof code. ## Proof-only Solidity Vault audit -`Contracts/SolidityVault/Proof.lean` prints the axioms of every theorem. +`Contracts/Vault/Proofs/Execution.lean` prints the axioms of every theorem. `python3 scripts/check_solidity_contract.py` re-executes that audit and requires coverage of all declared theorems, rejecting `sorryAx` and project axioms. -The exercised Vault proofs report only the standard Lean foundations `propext` -and `Quot.sound`; they do not depend on `solidityMappingSlot_injective`. +The shared Vault proofs report only the standard Lean foundations `propext`, +`Classical.choice`, and `Quot.sound`; they do not depend on `solidityMappingSlot_injective`. This does not remove the trusted Solidity frontend/translation boundary described in `TRUST_ASSUMPTIONS.md`, or change the compiler axiom registry below. diff --git a/Contracts/SolidityVault/Contract.lean b/Contracts/SolidityVault/Contract.lean deleted file mode 100644 index c05209f20e..0000000000 --- a/Contracts/SolidityVault/Contract.lean +++ /dev/null @@ -1,7 +0,0 @@ -import Verity.Solidity - -namespace Contracts.SolidityVault - -solidity_contract Imported from "../../examples/solidity/Vault.sol" - -end Contracts.SolidityVault diff --git a/Contracts/SolidityVault/Proof.lean b/Contracts/SolidityVault/Proof.lean deleted file mode 100644 index 276430b78a..0000000000 --- a/Contracts/SolidityVault/Proof.lean +++ /dev/null @@ -1,116 +0,0 @@ -import Contracts.SolidityVault.Spec - -namespace Contracts.SolidityVault -open Verity -open Verity.Stdlib.Math - -macro "reduce_import" : tactic => `(tactic| - simp_all [Spec.deposit, Spec.withdraw, Spec.balance, Spec.accountingState, - Imported.deposit, Imported.withdraw, Imported.balanceOf, - Imported.totalAssetsSlot, Imported.totalSupplySlot, Imported.shareBalancesSlot, - Contract.run, Bind.bind, Pure.pure, Verity.instMonadContract, Verity.bind, Verity.pure, msgValue, msgSender, Verity.require, - getStorage, setStorage, getMapping, setMapping, requireSomeUint, safeSub, - Nat.not_le_of_lt, Nat.not_lt_of_ge, - ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, - ContractState.writeMap, ContractState.storage, ContractState.storageMap]) - -theorem balance_meets_spec (s : ContractState) (account : Address) - (h0 : s.msgValue = 0) : Spec.balance s account := by - reduce_import - -theorem deposit_meets_spec (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : - Spec.deposit s amount := by - reduce_import - -theorem withdraw_meets_spec (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) - (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw s amount := by - reduce_import - -theorem deposit_nonpayable (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (Imported.deposit amount).run s = ContractResult.revert "Nonpayable" s := by - reduce_import - -/-- A late failing addition rolls back the earlier mapping and asset writes. -/ -theorem deposit_late_overflow_rollback (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = none) : - (Imported.deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by - reduce_import - -theorem withdraw_insufficient_shares (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : - (Imported.withdraw amount).run s = ContractResult.revert "InsufficientShares" s := by - reduce_import - -/-- Successful deposit changes no unrelated logical storage key. -/ -theorem deposit_frame (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) - (key : StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) - (hkm : key ≠ .map 2 s.sender) : - ((Imported.deposit amount).run s).snd.storageWords key = s.storageWords key := by - have h := deposit_meets_spec s amount h0 hs ha ht - rw [Spec.deposit] at h - rw [h] - simp [Spec.accountingState, Imported.totalAssetsSlot, Imported.totalSupplySlot, - Imported.shareBalancesSlot, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] - -theorem totalAssets_getter (s : ContractState) (h0 : s.msgValue = 0) : - Imported.totalAssets.run s = ContractResult.success (s.readSlot 0) s := by - simp [Imported.totalAssets, Imported.totalAssetsSlot, Contract.run, Verity.bind, - msgValue, Verity.require, getStorage, h0] - -theorem totalSupply_getter (s : ContractState) (h0 : s.msgValue = 0) : - Imported.totalSupply.run s = ContractResult.success (s.readSlot 1) s := by - simp [Imported.totalSupply, Imported.totalSupplySlot, Contract.run, Verity.bind, - msgValue, Verity.require, getStorage, h0] - -theorem shareBalances_getter (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : - (Imported.shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by - simp [Imported.shareBalances, Imported.shareBalancesSlot, Contract.run, Verity.bind, - msgValue, Verity.require, getMapping, h0] - -theorem withdraw_nonpayable (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (Imported.withdraw amount).run s = ContractResult.revert "Nonpayable" s := by - reduce_import - -theorem withdraw_insufficient_assets (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : (s.readSlot 0).val < amount.val) : - (Imported.withdraw amount).run s = ContractResult.revert "InsufficientAssets" s := by - reduce_import - -theorem withdraw_insufficient_supply (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : - (Imported.withdraw amount).run s = ContractResult.revert "InsufficientSupply" s := by - reduce_import - -#print axioms deposit_frame -#print axioms totalAssets_getter -#print axioms totalSupply_getter -#print axioms shareBalances_getter -#print axioms withdraw_nonpayable -#print axioms withdraw_insufficient_assets -#print axioms withdraw_insufficient_supply -#print axioms balance_meets_spec -#print axioms deposit_meets_spec -#print axioms withdraw_meets_spec -#print axioms deposit_nonpayable -#print axioms deposit_late_overflow_rollback -#print axioms withdraw_insufficient_shares - -end Contracts.SolidityVault diff --git a/Contracts/SolidityVault/Spec.lean b/Contracts/SolidityVault/Spec.lean deleted file mode 100644 index ab0b525565..0000000000 --- a/Contracts/SolidityVault/Spec.lean +++ /dev/null @@ -1,29 +0,0 @@ -import Contracts.SolidityVault.Contract - -namespace Contracts.SolidityVault.Spec -open Verity - -/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ -def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := - let mapped := { s.writeMap Imported.shareBalancesSlot.slot s.sender shares with - knownAddresses := fun slot => if slot == Imported.shareBalancesSlot.slot then - (s.knownAddresses slot).insert s.sender else s.knownAddresses slot } - (mapped.writeSlot Imported.totalAssetsSlot.slot assets).writeSlot Imported.totalSupplySlot.slot supply - -def deposit (s : ContractState) (amount : Uint256) : Prop := - (Imported.deposit amount).run s = ContractResult.success () - (accountingState s (s.readMap Imported.shareBalancesSlot.slot s.sender + amount) - (s.readSlot Imported.totalAssetsSlot.slot + amount) - (s.readSlot Imported.totalSupplySlot.slot + amount)) - -def withdraw (s : ContractState) (amount : Uint256) : Prop := - (Imported.withdraw amount).run s = ContractResult.success () - (accountingState s (s.readMap Imported.shareBalancesSlot.slot s.sender - amount) - (s.readSlot Imported.totalAssetsSlot.slot - amount) - (s.readSlot Imported.totalSupplySlot.slot - amount)) - -def balance (s : ContractState) (account : Address) : Prop := - (Imported.balanceOf account).run s = - ContractResult.success (s.readMap Imported.shareBalancesSlot.slot account) s - -end Contracts.SolidityVault.Spec diff --git a/Contracts/Vault/Implementations.lean b/Contracts/Vault/Implementations.lean new file mode 100644 index 0000000000..e092735074 --- /dev/null +++ b/Contracts/Vault/Implementations.lean @@ -0,0 +1,58 @@ +import Contracts.Vault.Vault +import Contracts.Vault.Solidity + +namespace Contracts.Vault +open Verity + +-- The entry adapter below is valid only while the native compiler metadata +-- marks every Vault function nonpayable. A payable edit must fail this check. +#guard Vault.spec.functions.all (fun fn => !fn.isPayable) + +/-- Two implementations, one explicit external-call boundary for shared proofs. +`verity_contract` function definitions are bodies; its compiler adds nonpayable +checks at dispatch. The Solidity importer already exposes guarded entrypoints. +This wrapper models that existing dispatch rule, not a change to either body. -/ +inductive Implementation where + | verity + | solidity + +def nonpayableEntry {α : Type} (body : Contract α) : Contract α := do + let value ← msgValue + require (value.val == 0) "Nonpayable" + body + +namespace Implementation + +def deposit (impl : Implementation) (assets : Uint256) : Contract Unit := + match impl with + | .verity => nonpayableEntry (Vault.deposit assets) + | .solidity => Solidity.deposit assets + +def withdraw (impl : Implementation) (shares : Uint256) : Contract Unit := + match impl with + | .verity => nonpayableEntry (Vault.withdraw shares) + | .solidity => Solidity.withdraw shares + +def balanceOf (impl : Implementation) (account : Address) : Contract Uint256 := + match impl with + | .verity => nonpayableEntry (Vault.balanceOf account) + | .solidity => Solidity.balanceOf account + +def totalAssets (impl : Implementation) : Contract Uint256 := + match impl with + | .verity => nonpayableEntry Vault.totalAssets + | .solidity => Solidity.totalAssets + +def totalSupply (impl : Implementation) : Contract Uint256 := + match impl with + | .verity => nonpayableEntry Vault.totalSupply + | .solidity => Solidity.totalSupply + +/-- The Solidity public-mapping getter has the same role as Verity's balanceOf. -/ +def shareBalances (impl : Implementation) (account : Address) : Contract Uint256 := + match impl with + | .verity => nonpayableEntry (Vault.balanceOf account) + | .solidity => Solidity.shareBalances account + +end Implementation +end Contracts.Vault diff --git a/Contracts/Vault/Proofs/Execution.lean b/Contracts/Vault/Proofs/Execution.lean new file mode 100644 index 0000000000..1d397a2119 --- /dev/null +++ b/Contracts/Vault/Proofs/Execution.lean @@ -0,0 +1,164 @@ +import Contracts.Vault.Spec +import Contracts.Vault.Implementations + +namespace Contracts.Vault.Execution +open Verity +open Verity.Stdlib.Math + +/-- The same proof tactic unfolds either implementation; it does not assume +that the imported body is equal to the handwritten one. -/ +macro "reduce_vault" : tactic => `(tactic| + simp_all [Spec.deposit_execution, Spec.withdraw_execution, Spec.balance_execution, + Spec.accountingState, Implementation.deposit, Implementation.withdraw, + Implementation.balanceOf, Implementation.totalAssets, Implementation.totalSupply, + Implementation.shareBalances, nonpayableEntry, + Vault.deposit, Vault.withdraw, Vault.balanceOf, Vault.totalAssets, Vault.totalSupply, + Vault.totalAssetsSlot, Vault.totalSupplySlot, Vault.shareBalancesSlot, + Solidity.deposit, Solidity.withdraw, Solidity.balanceOf, Solidity.totalAssets, + Solidity.totalSupply, Solidity.shareBalances, + Solidity.totalAssetsSlot, Solidity.totalSupplySlot, Solidity.shareBalancesSlot, + Contract.run, Bind.bind, Pure.pure, Verity.instMonadContract, Verity.bind, Verity.pure, + msgValue, msgSender, Verity.require, revertCustomError, formatCustomError, getStorage, setStorage, getMapping, setMapping, + requireSomeUint, safeSub, Verity.EVM.Uint256.sub, Nat.not_le_of_lt, Nat.not_lt_of_ge, + ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, + ContractState.writeMap, ContractState.storage, ContractState.storageMap]) + +theorem balance_meets_spec (impl : Implementation) (s : ContractState) (account : Address) + (h0 : s.msgValue = 0) : Spec.balance_execution impl.balanceOf s account := by + cases impl <;> reduce_vault + +theorem deposit_meets_spec (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : + Spec.deposit_execution impl.deposit s amount := by + cases impl <;> reduce_vault + +theorem withdraw_meets_spec (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) + (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw_execution impl.withdraw s amount := by + cases impl <;> reduce_vault + rfl + +theorem deposit_nonpayable (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (impl.deposit amount).run s = ContractResult.revert "Nonpayable" s := by + cases impl <;> reduce_vault + +/-- A late failing addition rolls back the earlier mapping and asset writes. -/ +theorem deposit_late_overflow_rollback (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = none) : + (impl.deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by + cases impl <;> reduce_vault + +theorem withdraw_insufficient_shares (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : + (impl.withdraw amount).run s = ContractResult.revert "InsufficientShares()" s := by + cases impl <;> reduce_vault + +/-- Successful deposit changes no unrelated logical storage key. -/ +theorem deposit_frame (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) + (key : Verity.StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) + (hkm : key ≠ .map 2 s.sender) : + ((impl.deposit amount).run s).snd.storageWords key = s.storageWords key := by + have h := deposit_meets_spec impl s amount h0 hs ha ht + rw [Spec.deposit_execution] at h + rw [h] + simp [Spec.accountingState, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] + +theorem totalAssets_getter (impl : Implementation) (s : ContractState) (h0 : s.msgValue = 0) : + impl.totalAssets.run s = ContractResult.success (s.readSlot 0) s := by + cases impl <;> reduce_vault + +theorem totalSupply_getter (impl : Implementation) (s : ContractState) (h0 : s.msgValue = 0) : + impl.totalSupply.run s = ContractResult.success (s.readSlot 1) s := by + cases impl <;> reduce_vault + +theorem shareBalances_getter (impl : Implementation) (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : + (impl.shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by + cases impl <;> reduce_vault + +theorem withdraw_nonpayable (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (impl.withdraw amount).run s = ContractResult.revert "Nonpayable" s := by + cases impl <;> reduce_vault + +theorem withdraw_insufficient_assets (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : (s.readSlot 0).val < amount.val) : + (impl.withdraw amount).run s = ContractResult.revert "InsufficientAssets()" s := by + cases impl <;> reduce_vault + +theorem withdraw_insufficient_supply (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : + (impl.withdraw amount).run s = ContractResult.revert "InsufficientSupply()" s := by + cases impl <;> reduce_vault + +/-- Both implementations satisfy the pre-existing public deposit specification. -/ +theorem deposit_existing_spec (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : + Spec.deposit_spec amount s ((impl.deposit amount).run s).snd := by + have h := deposit_meets_spec impl s amount h0 hs ha ht + rw [Spec.deposit_execution] at h + rw [h] + simp +contextual [Spec.deposit_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, + Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, + Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, + Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, + ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage, ContractState.storageMap, ContractState.storageAddr, + Verity.EVM.Uint256.add] + repeat' constructor + all_goals exact Verity.Core.Uint256.add_comm _ _ + +/-- Both implementations satisfy the pre-existing public withdrawal specification. -/ +theorem withdraw_existing_spec (impl : Implementation) (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) + (ht : amount.val ≤ (s.readSlot 1).val) : + Spec.withdraw_spec amount s ((impl.withdraw amount).run s).snd := by + have h := withdraw_meets_spec impl s amount h0 hs ha ht + rw [Spec.withdraw_execution] at h + rw [h] + simp +contextual [Spec.withdraw_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, + Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, + Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, + Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, + ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage, ContractState.storageMap, ContractState.storageAddr, + Verity.EVM.Uint256.sub] + repeat' constructor + +#print axioms deposit_existing_spec +#print axioms withdraw_existing_spec + +#print axioms deposit_frame +#print axioms totalAssets_getter +#print axioms totalSupply_getter +#print axioms shareBalances_getter +#print axioms withdraw_nonpayable +#print axioms withdraw_insufficient_assets +#print axioms withdraw_insufficient_supply +#print axioms balance_meets_spec +#print axioms deposit_meets_spec +#print axioms withdraw_meets_spec +#print axioms deposit_nonpayable +#print axioms deposit_late_overflow_rollback +#print axioms withdraw_insufficient_shares + +end Contracts.Vault.Execution diff --git a/Contracts/Vault/README.md b/Contracts/Vault/README.md new file mode 100644 index 0000000000..080c2a7279 --- /dev/null +++ b/Contracts/Vault/README.md @@ -0,0 +1,50 @@ +# One Vault, two implementations + +Choose how to write the contract; reuse the same specification and proof file. + +| File | Role | +| --- | --- | +| `Vault.lean` | Write the Vault directly with `verity_contract`. | +| `../../examples/solidity/Vault.sol` | Existing Solidity implementation, unchanged. | +| `Solidity.lean` | One `solidity_contract` declaration imports that Solidity file. | +| `Implementations.lean` | Select `.verity` or `.solidity` at the same typed external-call boundary. | +| `Spec.lean` | Shared requirements, including the original accounting specs. | +| `Proofs/Execution.lean` | One proof suite parameterized by the selected implementation. | + +For example, `Execution.deposit_existing_spec .verity` and +`Execution.deposit_existing_spec .solidity` are the **same theorem and proof**, +instantiated with different implementations. Neither branch assumes an unproved +correspondence; Lean checks both bodies. Existing `Proofs/Correctness.lean` +continues to check the original getter proofs. + +## Developer workflow + +1. Keep the contract in Solidity, or write it directly in Verity. +2. For Solidity, add the thin import declaration shown in `Solidity.lean`. +3. Write/reuse `Spec.lean`, then check the proofs with `lake build SolidityVault`. +4. After editing Solidity, rebuild and reload the Lean editor; live Solidity + watching is not implemented. See the root README for pinned-solc setup. + +The POC is registered for this Vault only, not arbitrary Solidity projects. + +Under the hood: `solc` resolves Solidity types/references; +`scripts/solidity_contract.py` validates its AST and prepares structured JSON; +`Verity/Solidity.lean` turns it into checked Verity definitions in memory. +The separate `scripts/check_solidity_contract.py` is the **maintainer test suite**, +not a second developer translation step. No generated model `.lean` or bytecode. + +## Matching behavior, not just names + +Verity already rejects ETH on nonpayable external calls in its compiler dispatcher +(`Compiler/CodegenCommon.lean`, `callvalueGuard`/`dispatchBody`). Its bare Lean +function definitions represent bodies. `Implementations.lean` exposes this guard +explicitly for shared proofs; the imported entrypoints already include it. +This adapter is not a proved full compiler-dispatch bridge. + +Both versions use matching withdrawal custom errors (`InsufficientShares()`, etc.) +and deposit write order. Shared proofs cover success, frame preservation, +nonpayability, withdrawal errors and late-overflow rollback, using the same +specification and hypotheses. Solidity's public mapping getter corresponds to +native `balanceOf` in the shared interface. Error labels and logical storage are +model representations; exact revert bytes, deployment and full Solidity/EVM +correspondence are not proved. The importer remains trusted. diff --git a/Contracts/Vault/Solidity.lean b/Contracts/Vault/Solidity.lean new file mode 100644 index 0000000000..11477245a1 --- /dev/null +++ b/Contracts/Vault/Solidity.lean @@ -0,0 +1,7 @@ +import Verity.Solidity + +namespace Contracts.Vault + +solidity_contract Solidity from "../../examples/solidity/Vault.sol" + +end Contracts.Vault diff --git a/Contracts/Vault/Spec.lean b/Contracts/Vault/Spec.lean index 2efe46b7d0..9360e17608 100644 --- a/Contracts/Vault/Spec.lean +++ b/Contracts/Vault/Spec.lean @@ -55,4 +55,28 @@ def withdraw_share_sum_equation (shares : Uint256) (s s' : ContractState) : Prop def assets_supply_synced (s : ContractState) : Prop := s.storage 0 = s.storage 1 +/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ +def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := + let mapped := { s.writeMap 2 s.sender shares with + knownAddresses := fun slotIdx => if slotIdx == 2 then + (s.knownAddresses slotIdx).insert s.sender else s.knownAddresses slotIdx } + (mapped.writeSlot 0 assets).writeSlot 1 supply + +def deposit_execution (deposit : Uint256 → Contract Unit) (s : ContractState) (amount : Uint256) : Prop := + (deposit amount).run s = ContractResult.success () + (accountingState s (s.readMap 2 s.sender + amount) + (s.readSlot 0 + amount) + (s.readSlot 1 + amount)) + +def withdraw_execution (withdraw : Uint256 → Contract Unit) (s : ContractState) (amount : Uint256) : Prop := + (withdraw amount).run s = ContractResult.success () + (accountingState s (s.readMap 2 s.sender - amount) + (s.readSlot 0 - amount) + (s.readSlot 1 - amount)) + +def balance_execution (balanceOf : Address → Contract Uint256) (s : ContractState) (account : Address) : Prop := + (balanceOf account).run s = + ContractResult.success (s.readMap 2 account) s + + end Contracts.Vault.Spec diff --git a/Contracts/Vault/Vault.lean b/Contracts/Vault/Vault.lean index 9072c6fd7a..00e2eef8fa 100644 --- a/Contracts/Vault/Vault.lean +++ b/Contracts/Vault/Vault.lean @@ -19,6 +19,11 @@ verity_contract Vault where totalSupplySlot : Uint256 := slot 1 shareBalancesSlot : Address → Uint256 := slot 2 + errors + error InsufficientShares() + error InsufficientAssets() + error InsufficientSupply() + constructor () := do setStorage totalAssetsSlot 0 setStorage totalSupplySlot 0 @@ -26,23 +31,23 @@ verity_contract Vault where function deposit (assets : Uint256) : Unit := do let sender ← msgSender let currentShares ← getMapping shareBalancesSlot sender - let newShares ← requireSomeUint (safeAdd currentShares assets) "Share balance overflow" - let currentAssets ← getStorage totalAssetsSlot - let newAssets ← requireSomeUint (safeAdd currentAssets assets) "Total assets overflow" - let currentSupply ← getStorage totalSupplySlot - let newSupply ← requireSomeUint (safeAdd currentSupply assets) "Total supply overflow" + let newShares ← requireSomeUint (safeAdd currentShares assets) "Panic(0x11)" setMapping shareBalancesSlot sender newShares + let currentAssets ← getStorage totalAssetsSlot + let newAssets ← requireSomeUint (safeAdd currentAssets assets) "Panic(0x11)" setStorage totalAssetsSlot newAssets + let currentSupply ← getStorage totalSupplySlot + let newSupply ← requireSomeUint (safeAdd currentSupply assets) "Panic(0x11)" setStorage totalSupplySlot newSupply function withdraw (shares : Uint256) : Unit := do let sender ← msgSender let currentShares ← getMapping shareBalancesSlot sender - require (currentShares >= shares) "Insufficient shares" + requireError (currentShares >= shares) InsufficientShares() let currentAssets ← getStorage totalAssetsSlot - require (currentAssets >= shares) "Insufficient assets" + requireError (currentAssets >= shares) InsufficientAssets() let currentSupply ← getStorage totalSupplySlot - require (currentSupply >= shares) "Insufficient supply" + requireError (currentSupply >= shares) InsufficientSupply() setMapping shareBalancesSlot sender (sub currentShares shares) setStorage totalAssetsSlot (sub currentAssets shares) setStorage totalSupplySlot (sub currentSupply shares) diff --git a/README.md b/README.md index 83f14d77da..0b28abca24 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,14 @@ ## Proof-only Solidity Vault import (POC) -`Contracts/SolidityVault/Contract.lean` imports the existing -`examples/solidity/Vault.sol` with `solidity_contract Imported from +`Contracts/Vault/Solidity.lean` imports the existing +`examples/solidity/Vault.sol` with `solidity_contract Solidity from "../../examples/solidity/Vault.sol"`. The frontend requests typed AST and storage layout from pinned solc 0.8.33, then registers transparent, kernel-checked `Verity.Contract` definitions directly in memory. There is no generated model -`.lean`, CompilationModel, or bytecode. `Spec.lean` and `Proof.lean` refer to those -imported executions, not the handwritten Vault implementation. +`.lean`, CompilationModel, or bytecode. Both the handwritten and imported Vault +use the same `Contracts/Vault/Spec.lean` and `Proofs/Execution.lean`. +See [Vault's two-implementation walkthrough](Contracts/Vault/README.md). With the Lean/package prerequisites installed, put the pinned Linux solc binary at `.lake/solidity-import/solc` (executable; SHA-256 diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index cefcb05948..2b0ab35a04 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -31,6 +31,17 @@ or full EVM equivalence claim. Initial states are arbitrary, not proven deployed states. Arithmetic success premises restrict success theorems; separate failure proofs cover nonpayability, insufficient balances and late-overflow rollback. +Both implementations use the shared Vault specification and execution proof file. +`Implementations.lean` explicitly wraps native function bodies with the nonpayable +entry check already present in `Compiler/CodegenCommon.lean:dispatchBody`; imported +entrypoints already contain this guard. This is a proof-facing entry adapter, not +a new compiler rule or a theorem bridging the wrapper to deployed dispatch. +Zero-argument custom errors use Verity's `Name()` model convention. The native +Vault declares typed withdrawal errors; arithmetic panic strings remain a model +representation, not an assertion of matching EVM revert bytes. Native deposit +write order follows the Solidity source. The shared statements do not assert +full equivalence of all executions or all public/deployment interfaces. + Lake's dedicated `SolidityVault` target tracks source/compiler/Python-and-Lean-importer/build policy bytes and normal Lean dependencies. Acceptance evidence is obtained with `python3 scripts/check_solidity_contract.py`; stale editor snapshots are not a diff --git a/lakefile.lean b/lakefile.lean index d94846e7b4..01b5afd122 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -46,7 +46,8 @@ lean_lib «SolidityFrontend» where globs := #[.one `Verity.Solidity] lean_lib «SolidityVault» where - globs := #[.submodules `Contracts.SolidityVault] + globs := #[.one `Contracts.Vault.Solidity, .one `Contracts.Vault.Implementations, + .one `Contracts.Vault.Proofs.Execution] needs := #[vaultSolidity, vaultFrontend, vaultLeanImporter, vaultSolc, vaultBuildPolicy] lean_lib «Contracts» where @@ -65,7 +66,11 @@ lean_lib «Contracts» where .andSubmodules `Contracts.OwnedCounterComposed, .andSubmodules `Contracts.SafeCounter, .andSubmodules `Contracts.Ledger, - .andSubmodules `Contracts.Vault, + .one `Contracts.Vault, .one `Contracts.Vault.Vault, + .one `Contracts.Vault.Spec, .one `Contracts.Vault.Invariants, + .one `Contracts.Vault.SpecProofs, .one `Contracts.Vault.Proofs.Basic, + .one `Contracts.Vault.Proofs.Correctness, .one `Contracts.Vault.Proofs.Conservation, + .one `Contracts.Vault.Proofs.Native, .andSubmodules `Contracts.ERC20, .andSubmodules `Contracts.ERC721, .andSubmodules `Contracts.SimpleToken, diff --git a/scripts/check_solidity_contract.py b/scripts/check_solidity_contract.py index b597e661d6..9c99b818da 100644 --- a/scripts/check_solidity_contract.py +++ b/scripts/check_solidity_contract.py @@ -35,7 +35,7 @@ def main(): with tempfile.TemporaryDirectory(prefix='verity-vault-check-') as directory: root = Path(directory) # Copy mutable build outputs (never hardlink); share only prebuilt dependencies. - for name in ('Verity', 'Contracts/SolidityVault', 'scripts', 'examples/solidity'): + for name in ('Verity', 'Compiler', 'Contracts', 'scripts', 'examples/solidity'): shutil.copytree(ROOT / name, root / name) for name in ('lakefile.lean', 'lake-manifest.json', 'lean-toolchain'): shutil.copy2(ROOT / name, root / name) @@ -59,15 +59,15 @@ def model(success=True, contains=None): return run(root, ['python3', str(frontend), str(source)], success, contains) def artifacts(): return {str(p.relative_to(root)): (p.stat().st_mtime_ns, hashlib.sha256(p.read_bytes()).hexdigest()) - for p in (root / '.lake/build/lib/lean/Contracts/SolidityVault').glob('*.olean')} + for p in (root / '.lake/build/lib/lean/Contracts/Vault').rglob('*.olean')} def caches(): return {p.name: (p.stat().st_mtime_ns, p.read_bytes()) for p in compiler.parent.glob('*.json')} build() check(True, 'baseline lake build SolidityVault') - proof = root / 'Contracts/SolidityVault/Proof.lean' + proof = root / 'Contracts/Vault/Proofs/Execution.lean' theorem_names = re.findall(r'^theorem\s+(\w+)', proof.read_text(), re.M) audit = run(root, ['lake', 'env', 'lean', str(proof)]) - entries = re.findall(r"'Contracts.SolidityVault.(\w+)' depends on axioms: \[([^\]]*)\]", audit) + entries = re.findall(r"'Contracts.Vault.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", audit) check(set(theorem_names) == {name for name, _ in entries}, 'every theorem appears in actual #print axioms output') axioms = {a.strip() for _, values in entries for a in values.split(',') if a.strip()} check(axioms <= {'propext', 'Quot.sound', 'Classical.choice'}, 'no project axioms or sorryAx: ' + ', '.join(sorted(axioms))) @@ -141,12 +141,28 @@ def need(ok, n, why): edit(original.replace(old, new)) changed_model = json.loads(model()) check(changed_model['functions'] != baseline_model['functions'], name + ' changes accepted AST behavior') - out = build(False, 'Contracts.SolidityVault.Proof') + out = build(False, 'Contracts.Vault.Proofs.Execution') check('unsolved goals' in out or 'Type mismatch' in out or 'type mismatch' in out, name + ' preserved-mtime source edit rebuilds and breaks existing proof') check(before != artifacts(), name + ' refreshes dependent oleans') edit(original) build() + # Both branches really participate in the shared proof; the entry adapter + # cannot silently conceal a native payable declaration. + native = root / 'Contracts/Vault/Vault.lean' + native_original = native.read_bytes() + try: + native.write_bytes(native_original.replace(b'InsufficientShares', b'NotEnoughShares')) + build(False, 'Contracts.Vault.Proofs.Execution') + check(True, 'native custom-error mutation breaks the same shared proof') + native.write_bytes(native_original) + build() + native.write_bytes(native_original.replace(b'function deposit', b'function payable deposit')) + build(False, 'did not evaluate to `true`') + check(True, 'native payable mutation rejected by entry-boundary metadata check') + finally: + native.write_bytes(native_original) + build() for name, old, new, diagnostic in ( ('contract layout at', b'contract Vault {', b'contract Vault layout at 100 {', 'layout at'), ('initializer', b'uint256 public totalAssets;', b'uint256 public totalAssets = 1;', 'initializer'), diff --git a/scripts/solidity_contract.py b/scripts/solidity_contract.py index 592eaf2bac..743483f71f 100644 --- a/scripts/solidity_contract.py +++ b/scripts/solidity_contract.py @@ -314,7 +314,8 @@ def statements(nodes, scope, returns): need(callee['nodeType'] == 'Identifier' and callee['referencedDeclaration'] in errors and not call['arguments'], n, 'unsupported revert') condition = expr(n['condition'], scope) need(condition[0] == '<', n, 'only uint256 comparison guard supported') - result.append(['guard', condition, errors[callee['referencedDeclaration']]]) + # Match Verity's zero-argument custom-error display convention. + result.append(['guard', condition, errors[callee['referencedDeclaration']] + '()']) elif k == 'Return': need(i == len(nodes)-1 and returns == 'uint256' and n['expression'] is not None, n, 'only terminal scalar return') result.append(['return', expr(n['expression'], scope)]) From 432da464a4021543a3537b18199fc5f8355ecdd1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 17:22:45 +0100 Subject: [PATCH 4/8] chore: auto-refresh derived artifacts --- PrintAxioms.lean | 20 +++++++++++++++++++- artifacts/verification_status.json | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/PrintAxioms.lean b/PrintAxioms.lean index bbf05cfad6..c9a263c6da 100644 --- a/PrintAxioms.lean +++ b/PrintAxioms.lean @@ -34,6 +34,7 @@ import Contracts.SimpleToken.Proofs.Correctness import Contracts.SimpleToken.Proofs.Isolation import Contracts.SimpleToken.Proofs.Supply import Contracts.Vault.Proofs.Correctness +import Contracts.Vault.Proofs.Execution import Contracts.Vault.Proofs.Native import Verity.Proofs.CheckedExternalCallConsumer import Verity.Proofs.LoopSimulationResultAware @@ -682,6 +683,23 @@ end Verity.AxiomAudit Contracts.Vault.Proofs.balanceOf_meets_spec Contracts.Vault.Proofs.balanceOf_preserves_state + -- Contracts/Vault/Proofs/Execution.lean + Contracts.Vault.Execution.balance_meets_spec + Contracts.Vault.Execution.deposit_meets_spec + Contracts.Vault.Execution.withdraw_meets_spec + Contracts.Vault.Execution.deposit_nonpayable + Contracts.Vault.Execution.deposit_late_overflow_rollback + Contracts.Vault.Execution.withdraw_insufficient_shares + Contracts.Vault.Execution.deposit_frame + Contracts.Vault.Execution.totalAssets_getter + Contracts.Vault.Execution.totalSupply_getter + Contracts.Vault.Execution.shareBalances_getter + Contracts.Vault.Execution.withdraw_nonpayable + Contracts.Vault.Execution.withdraw_insufficient_assets + Contracts.Vault.Execution.withdraw_insufficient_supply + Contracts.Vault.Execution.deposit_existing_spec + Contracts.Vault.Execution.withdraw_existing_spec + -- Contracts/Vault/Proofs/Native.lean Contracts.Vault.Proofs.Native.vaultMinimal_functions_bridged Contracts.Vault.Proofs.Native.vaultMinimal_runtime_lowers_native @@ -7515,4 +7533,4 @@ end Verity.AxiomAudit Compiler.Proofs.YulGeneration.YulTransaction.ofIR_args ] --- Total: 6953 theorems/lemmas (4963 public, 1990 private, 0 sorry'd) +-- Total: 6968 theorems/lemmas (4978 public, 1990 private, 0 sorry'd) diff --git a/artifacts/verification_status.json b/artifacts/verification_status.json index f376e81d4b..3ed6aeae48 100644 --- a/artifacts/verification_status.json +++ b/artifacts/verification_status.json @@ -1,7 +1,7 @@ { "codebase": { "core_lines": 2040, - "example_contracts": 19 + "example_contracts": 18 }, "proofs": { "axioms": 1, From c0051aa546ab04367636bbca206859bbed0d6549 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 10 Sep 2026 13:29:11 +0200 Subject: [PATCH 5/8] fix: harden Solidity import validation --- AUDIT.md | 5 +- AXIOMS.md | 7 +- Contracts/Vault/Proofs/Execution.lean | 17 ---- README.md | 4 +- TRUST_ASSUMPTIONS.md | 4 + Verity/Solidity.lean | 10 ++- scripts/check_solidity_contract.py | 111 +++++++++++++++++++++++++- 7 files changed, 132 insertions(+), 26 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 0abe1e64bb..66d4e8a248 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -9,7 +9,10 @@ boundary checks change. The focused suite also probes recursive AST rejection (including metadata), contract `layout at`, registered-source symlink escape, and Lean importer digest -sensitivity. The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not +sensitivity. It also checks safe transparent declarations, duplicate aliases, +a deliberately malformed late declaration and complete registration rollback, +and a cold-cache build with new network sockets denied using strace. +The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not a transitive build identity. Evidence command: `python3 scripts/check_solidity_contract.py` (after diff --git a/AXIOMS.md b/AXIOMS.md index 2c1c8ad0e1..1ce0b39e3f 100644 --- a/AXIOMS.md +++ b/AXIOMS.md @@ -4,9 +4,12 @@ This file is the authoritative registry of axioms used by Verity proof code. ## Proof-only Solidity Vault audit -`Contracts/Vault/Proofs/Execution.lean` prints the axioms of every theorem. -`python3 scripts/check_solidity_contract.py` re-executes that audit and requires +`PrintAxioms.lean` includes the shared Vault execution theorems. The focused +`python3 scripts/check_solidity_contract.py` runs `#print axioms` in a disposable +audit module for every theorem in `Contracts/Vault/Proofs/Execution.lean` and requires coverage of all declared theorems, rejecting `sorryAx` and project axioms. +Its malformed-declaration probe also checks that kernel error recovery does not +leave any partial declarations or fallback axioms in the import namespace. The shared Vault proofs report only the standard Lean foundations `propext`, `Classical.choice`, and `Quot.sound`; they do not depend on `solidityMappingSlot_injective`. This does not remove the trusted Solidity frontend/translation boundary described diff --git a/Contracts/Vault/Proofs/Execution.lean b/Contracts/Vault/Proofs/Execution.lean index 1d397a2119..6d40d5ea29 100644 --- a/Contracts/Vault/Proofs/Execution.lean +++ b/Contracts/Vault/Proofs/Execution.lean @@ -144,21 +144,4 @@ theorem withdraw_existing_spec (impl : Implementation) (s : ContractState) (amou Verity.EVM.Uint256.sub] repeat' constructor -#print axioms deposit_existing_spec -#print axioms withdraw_existing_spec - -#print axioms deposit_frame -#print axioms totalAssets_getter -#print axioms totalSupply_getter -#print axioms shareBalances_getter -#print axioms withdraw_nonpayable -#print axioms withdraw_insufficient_assets -#print axioms withdraw_insufficient_supply -#print axioms balance_meets_spec -#print axioms deposit_meets_spec -#print axioms withdraw_meets_spec -#print axioms deposit_nonpayable -#print axioms deposit_late_overflow_rollback -#print axioms withdraw_insufficient_shares - end Contracts.Vault.Execution diff --git a/README.md b/README.md index 0b28abca24..72b6ea36c8 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,9 @@ python3 scripts/check_solidity_contract.py The acceptance script uses disposable copies for source mutations, rejection, content-based Lake freshness, cache reuse, compiler/importer invalidation, and -an audit of every Vault theorem. It never mutates the original Solidity file. +an audit of every Vault theorem. It also tests declaration-registration rollback +and cold-cache builds with new sockets denied (the test runner requires Linux +`strace`; normal imports do not). It never mutates the original Solidity file. Save Solidity, rebuild this dedicated target, then reload the Lean editor: an already-open editor snapshot does not automatically watch `.sol` changes. See [the trust boundary](TRUST_ASSUMPTIONS.md#proof-only-solidity-vault-import). diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index 2b0ab35a04..b19162ba68 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -16,6 +16,10 @@ policy are tracked separately by normal build dependencies, not this digest. The recursive closed AST schema permits explicitly typed documentation and compiler metadata, but rejects unknown fields/node kinds and contract `layout at`. Canonical package containment is checked independently of source registration. +Declaration registration disables asynchronous kernel checking inside the import +transaction, restores the pre-import environment on failure, and checks each +body against its typed return signature before registration. Safe transparent +definitions are also compiled by Lean for ordinary executable consumers. Local AST caches are trusted build artifacts: their self-recorded hashes detect accidental corruption, not malicious replacement. diff --git a/Verity/Solidity.lean b/Verity/Solidity.lean index 512d470662..70e6aa873d 100644 --- a/Verity/Solidity.lean +++ b/Verity/Solidity.lean @@ -182,7 +182,11 @@ private def importModel (ns : Name) (model : Json) : MetaM Unit := do register (ns ++ Name.mkSimple getter) value for f in functions do let value ← params (← arr (← field f "params")).toList [] fun locals => do - nonpayable (← body slots locals (← arr (← field f "body")).toList) + let code ← body slots locals (← arr (← field f "body")).toList + let expected ← mkAppM ``Verity.Contract #[← valueType (← str (← field f "returns"))] + unless ← isDefEq (← inferType code) expected do + throwError "imported body does not match typed AST return signature" + nonpayable code register (ns ++ Name.mkSimple (← str (← field f "name"))) value register (ns ++ `sourceDigest) (mkStrLit (← str (← field model "digest"))) @@ -205,7 +209,9 @@ syntax (name := solidityContract) "solidity_contract " ident " from " str : comm | .ok j => pure j | .error e => throwError "invalid frontend JSON: {e}" let ns := (← getCurrNamespace) ++ stx[1].getId - liftTermElabM <| importModel ns model + -- A checking error must be raised inside this transaction, not in a later + -- async task after partial declarations have escaped the rollback handler. + liftTermElabM <| withOptions (Elab.async.set · false) (importModel ns model) catch e => setEnv saved throw e diff --git a/scripts/check_solidity_contract.py b/scripts/check_solidity_contract.py index 9c99b818da..657f5e0ec3 100644 --- a/scripts/check_solidity_contract.py +++ b/scripts/check_solidity_contract.py @@ -32,7 +32,7 @@ def run(root, args, success=True, contains=None): def main(): - with tempfile.TemporaryDirectory(prefix='verity-vault-check-') as directory: + with tempfile.TemporaryDirectory(prefix='verity-vault-check-', dir=ROOT.parent) as directory: root = Path(directory) # Copy mutable build outputs (never hardlink); share only prebuilt dependencies. for name in ('Verity', 'Compiler', 'Contracts', 'scripts', 'examples/solidity'): @@ -66,7 +66,13 @@ def caches(): check(True, 'baseline lake build SolidityVault') proof = root / 'Contracts/Vault/Proofs/Execution.lean' theorem_names = re.findall(r'^theorem\s+(\w+)', proof.read_text(), re.M) - audit = run(root, ['lake', 'env', 'lean', str(proof)]) + audit_file = root / '.lake/solidity-import/AxiomAudit.lean' + try: + audit_file.write_text('import Contracts.Vault.Proofs.Execution\n' + + '\n'.join('#print axioms Contracts.Vault.Execution.' + name for name in theorem_names) + '\n') + audit = run(root, ['lake', 'env', 'lean', str(audit_file)]) + finally: + audit_file.unlink(missing_ok=True) entries = re.findall(r"'Contracts.Vault.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", audit) check(set(theorem_names) == {name for name, _ in entries}, 'every theorem appears in actual #print axioms output') axioms = {a.strip() for _, values in entries for a in values.split(',') if a.strip()} @@ -114,7 +120,7 @@ def need(ok, n, why): check(all('rejected ' + name in probe for name in ('unknown child', 'altered block', 'metadata child')), 'closed recursive AST schema rejects unknown executable children and altered body kind; accepts documentation') # Even the registered source must remain inside the canonical package root. - with tempfile.TemporaryDirectory(prefix='verity-vault-outside-') as outside: + with tempfile.TemporaryDirectory(prefix='verity-vault-outside-', dir=ROOT.parent) as outside: escaped = Path(outside) / 'Vault.sol' escaped.write_bytes(original) source.unlink() @@ -221,6 +227,105 @@ def need(ok, n, why): check(True, 'compiler content change invalidates Lake and fails closed') compiler.write_bytes(compiler_original) build() + # Authored diagnostic snippets, not generated semantic/model source. + probe_file = root / '.lake/solidity-import/RegistrationProbe.lean' + try: + probe_file.write_text('''import Contracts.Vault.Solidity +open Lean Elab Command +run_cmd do + for suffix in ["totalAssetsSlot", "totalSupplySlot", "shareBalancesSlot", + "deposit", "withdraw", "balanceOf", "totalAssets", "totalSupply", + "shareBalances", "sourceDigest"] do + let name := `Contracts.Vault.Solidity ++ Name.mkSimple suffix + let some (.defnInfo info) := (← getEnv).find? name + | throwError "not a transparent definition: {name}" + unless info.safety == .safe && !info.value.hasMVar && !info.value.hasFVar do + throwError "unsafe or unclosed definition: {name}" + for dep in info.value.getUsedConstants do + if dep.toString.startsWith "Contracts." && + !dep.toString.startsWith "Contracts.Vault.Solidity." then + throwError "imported declaration depends on handwritten contract: {dep}" + let some (.defnInfo deposit) := (← getEnv).find? `Contracts.Vault.Solidity.deposit + | throwError "missing imported deposit" + for dep in [``Verity.setMapping, ``Verity.setStorage, ``Verity.Stdlib.Math.safeAdd] do + unless deposit.value.getUsedConstants.contains dep do + throwError "missing source-derived deposit operation: {dep}" + logInfo "CHECKED_TRANSPARENT_DECLARATIONS" +solidity_contract Existing from "../../examples/solidity/Vault.sol" +run_cmd do + let original ← getEnv + let mut rejected := false + try + SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Existing):ident from "../../examples/solidity/Vault.sol")) + catch _ => rejected := true + unless rejected do throwError "duplicate alias accepted" + let some (.defnInfo before) := original.find? `Existing.deposit + | throwError "missing initial declaration" + let some (.defnInfo after) := (← getEnv).find? `Existing.deposit + | throwError "lost initial declaration" + unless before.value == after.value && before.type == after.type do + throwError "duplicate alias changed prior declaration" + logInfo "DUPLICATE_ALIAS_REJECTED" +''') + result = run(root, ['lake', 'env', 'lean', str(probe_file)]) + check('CHECKED_TRANSPARENT_DECLARATIONS' in result and 'DUPLICATE_ALIAS_REJECTED' in result, + 'all ten imported declarations safe/transparent/closed; duplicate alias rejected without overwrite') + # Corrupt the type of a late declaration, after slots/getters were + # registered. The real command must synchronously catch the kernel + # error and restore the entire pre-import environment. + lean_original = lean_importer.read_bytes() + try: + lean_importer.write_bytes(lean_original.replace( + b' type := type\n', + b' type := if name.toString.endsWith ".deposit" then mkConst ``Nat else type\n')) + run(root, ['lake', 'build', 'SolidityFrontend']) + probe_file.write_text('''import Verity.Solidity +open Lean Elab Command +set_option Elab.async true +run_cmd do + let mut rejected := false + try + SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Broken):ident from "../../examples/solidity/Vault.sol")) + catch e => + rejected := true + logInfo m!"EXPECTED_KERNEL_ERROR {e.toMessageData}" + unless rejected do throwError "malformed declaration accepted" + for suffix in ["totalAssetsSlot", "totalSupplySlot", "shareBalancesSlot", + "totalAssets", "totalSupply", "shareBalances", "deposit", "sourceDigest"] do + if (← getEnv).contains (`Broken ++ Name.mkSimple suffix) then + throwError "partial/fallback declaration escaped rollback: {suffix}" + logInfo "KERNEL_REJECTION_ROLLED_BACK" +''') + result = run(root, ['lake', 'env', 'lean', str(probe_file)]) + check('KERNEL_REJECTION_ROLLED_BACK' in result and '(kernel)' in result, + 'malformed late declaration rejected synchronously; no partial definitions or fallback axioms escape') + finally: + lean_importer.write_bytes(lean_original) + build() + finally: + probe_file.unlink(missing_ok=True) + # Deliberately corrupt the frontend return metadata without replacing a + # body: Lean must reject the inconsistent typed interface before export. + try: + frontend.write_bytes(frontend_original.replace( + b'dict(name=name, params=params, returns=returns, body=', + b"dict(name=name, params=params, returns='unit', body=")) + build(False, 'imported body does not match typed AST return signature') + check(True, 'inconsistent typed return metadata rejected before declaration export') + finally: + frontend.write_bytes(frontend_original) + build() + # Cold compiler cache, with new sockets denied for the whole process tree. + # strace is an explicit test prerequisite, not needed by normal imports. + for path in compiler.parent.glob('*.json'): + path.unlink() + # Force only the imported wrapper to elaborate again, keeping prerequisites. + for path in (root / '.lake/build/lib/lean/Contracts/Vault').glob('Solidity.*'): + path.unlink() + run(root, ['strace', '-f', '-e', 'inject=socket:error=EPERM', '-o', + str(root / '.lake/solidity-import/offline.trace'), + 'lake', 'build', 'SolidityVault']) + check(bool(caches()), 'cold AST cache and current-source Lake build succeed with new network sockets denied') check(set(root.rglob('*.lean')) == lean_sources, 'no generated model .lean files') check(source.read_bytes() == original and frontend.read_bytes() == frontend_original, 'temporary source and importer restored; final baseline build passes') From 93d4240ebc47937c1615e4f2f32c696189007e34 Mon Sep 17 00:00:00 2001 From: Fricoben <78437165+fricoben@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:43:10 +0100 Subject: [PATCH 6/8] refactor: isolate Solidity Vault importer example --- AUDIT.md | 20 ++- AXIOMS.md | 8 +- Contracts/Vault/Implementations.lean | 58 ------- Contracts/Vault/Proofs/Execution.lean | 147 ------------------ Contracts/Vault/README.md | 50 ------ Contracts/Vault/Solidity.lean | 7 - Contracts/Vault/Spec.lean | 24 --- Contracts/Vault/Vault.lean | 21 +-- .../Importer/SolidityImporter.lean | 5 +- .../Importer/scripts/solidity_importer.py | 22 ++- .../scripts/solidity_importer_test.py | 83 +++++----- .../VaultFromSolidity/Proofs/Execution.lean | 136 ++++++++++++++++ Contracts/VaultFromSolidity/README.md | 36 +++++ Contracts/VaultFromSolidity/Spec.lean | 57 +++++++ .../VaultFromSolidity}/Vault.sol | 2 +- .../VaultFromSolidity/VaultFromSolidity.lean | 7 + PrintAxioms.lean | 36 ++--- README.md | 29 ++-- TRUST_ASSUMPTIONS.md | 27 ++-- artifacts/trust_surface_report.json | 2 +- artifacts/verification_status.json | 17 +- docs-site/public/llms.txt | 6 +- docs/VERIFICATION_STATUS.md | 14 +- lakefile.lean | 17 +- scripts/check_contract_structure.py | 3 + test/Vault.t.sol | 2 +- test/property_exclusions.json | 17 ++ test/property_manifest.json | 17 ++ 28 files changed, 428 insertions(+), 442 deletions(-) delete mode 100644 Contracts/Vault/Implementations.lean delete mode 100644 Contracts/Vault/Proofs/Execution.lean delete mode 100644 Contracts/Vault/README.md delete mode 100644 Contracts/Vault/Solidity.lean rename Verity/Solidity.lean => Contracts/VaultFromSolidity/Importer/SolidityImporter.lean (97%) rename scripts/solidity_contract.py => Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py (96%) rename scripts/check_solidity_contract.py => Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py (85%) create mode 100644 Contracts/VaultFromSolidity/Proofs/Execution.lean create mode 100644 Contracts/VaultFromSolidity/README.md create mode 100644 Contracts/VaultFromSolidity/Spec.lean rename {examples/solidity => Contracts/VaultFromSolidity}/Vault.sol (94%) create mode 100644 Contracts/VaultFromSolidity/VaultFromSolidity.lean diff --git a/AUDIT.md b/AUDIT.md index 66d4e8a248..ce13b82137 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -11,12 +11,13 @@ The focused suite also probes recursive AST rejection (including metadata), contract `layout at`, registered-source symlink escape, and Lean importer digest sensitivity. It also checks safe transparent declarations, duplicate aliases, a deliberately malformed late declaration and complete registration rollback, -and a cold-cache build with new network sockets denied using strace. +and a cold-cache build, with new network sockets denied using strace on Linux. The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not a transitive build identity. -Evidence command: `python3 scripts/check_solidity_contract.py` (after -`lake build SolidityVault` and installation of the pinned compiler). +Evidence command: +`python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py` +(after `lake build VaultFromSolidity` and installation of the pinned compiler). The focused runner builds and audits the imported execution proofs, changes accepted deposit/getter behavior while preserving source mtime and requires old proofs to fail, rejects unsupported source, checks unchanged artifacts/cache, @@ -24,14 +25,11 @@ and exercises Python-importer and compiler content invalidation. Mutations occur only in disposable copies. This is local acceptance evidence, not a new CI job, bytecode/runtime test, or proof of translation correctness. -The authored surface is existing `examples/solidity/Vault.sol` plus -`Contracts/Vault/{Solidity,Implementations,Spec}.lean` and `Proofs/Execution.lean`. -The same theorem statements and proofs are checked for `.verity` and `.solidity`, -including the pre-existing deposit/withdrawal specs. Native Vault now uses matching -typed custom errors and deposit write order. `Implementations.lean` makes the -nonpayable entry boundary explicit: native bodies get the compiler dispatch rule; -imported functions already include it. No generated model source or bytecode is emitted. Trust and axiom scope are recorded in -`TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. +The complete example surface lives under `Contracts/VaultFromSolidity`: Solidity +source, Python and Lean importers, specification, execution proofs and focused +acceptance tests. It is independent of the handwritten `Contracts/Vault` example. +No generated model source or bytecode is emitted. Trust and axiom scope are +recorded in `TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. ## Current Audit State diff --git a/AXIOMS.md b/AXIOMS.md index 1ce0b39e3f..b5a1e2e296 100644 --- a/AXIOMS.md +++ b/AXIOMS.md @@ -4,13 +4,13 @@ This file is the authoritative registry of axioms used by Verity proof code. ## Proof-only Solidity Vault audit -`PrintAxioms.lean` includes the shared Vault execution theorems. The focused -`python3 scripts/check_solidity_contract.py` runs `#print axioms` in a disposable -audit module for every theorem in `Contracts/Vault/Proofs/Execution.lean` and requires +`PrintAxioms.lean` includes the imported Vault execution theorems. The focused +`solidity_importer_test.py` runs `#print axioms` in a disposable audit module for +every theorem in `Contracts/VaultFromSolidity/Proofs/Execution.lean` and requires coverage of all declared theorems, rejecting `sorryAx` and project axioms. Its malformed-declaration probe also checks that kernel error recovery does not leave any partial declarations or fallback axioms in the import namespace. -The shared Vault proofs report only the standard Lean foundations `propext`, +The imported Vault proofs report only the standard Lean foundations `propext`, `Classical.choice`, and `Quot.sound`; they do not depend on `solidityMappingSlot_injective`. This does not remove the trusted Solidity frontend/translation boundary described in `TRUST_ASSUMPTIONS.md`, or change the compiler axiom registry below. diff --git a/Contracts/Vault/Implementations.lean b/Contracts/Vault/Implementations.lean deleted file mode 100644 index e092735074..0000000000 --- a/Contracts/Vault/Implementations.lean +++ /dev/null @@ -1,58 +0,0 @@ -import Contracts.Vault.Vault -import Contracts.Vault.Solidity - -namespace Contracts.Vault -open Verity - --- The entry adapter below is valid only while the native compiler metadata --- marks every Vault function nonpayable. A payable edit must fail this check. -#guard Vault.spec.functions.all (fun fn => !fn.isPayable) - -/-- Two implementations, one explicit external-call boundary for shared proofs. -`verity_contract` function definitions are bodies; its compiler adds nonpayable -checks at dispatch. The Solidity importer already exposes guarded entrypoints. -This wrapper models that existing dispatch rule, not a change to either body. -/ -inductive Implementation where - | verity - | solidity - -def nonpayableEntry {α : Type} (body : Contract α) : Contract α := do - let value ← msgValue - require (value.val == 0) "Nonpayable" - body - -namespace Implementation - -def deposit (impl : Implementation) (assets : Uint256) : Contract Unit := - match impl with - | .verity => nonpayableEntry (Vault.deposit assets) - | .solidity => Solidity.deposit assets - -def withdraw (impl : Implementation) (shares : Uint256) : Contract Unit := - match impl with - | .verity => nonpayableEntry (Vault.withdraw shares) - | .solidity => Solidity.withdraw shares - -def balanceOf (impl : Implementation) (account : Address) : Contract Uint256 := - match impl with - | .verity => nonpayableEntry (Vault.balanceOf account) - | .solidity => Solidity.balanceOf account - -def totalAssets (impl : Implementation) : Contract Uint256 := - match impl with - | .verity => nonpayableEntry Vault.totalAssets - | .solidity => Solidity.totalAssets - -def totalSupply (impl : Implementation) : Contract Uint256 := - match impl with - | .verity => nonpayableEntry Vault.totalSupply - | .solidity => Solidity.totalSupply - -/-- The Solidity public-mapping getter has the same role as Verity's balanceOf. -/ -def shareBalances (impl : Implementation) (account : Address) : Contract Uint256 := - match impl with - | .verity => nonpayableEntry (Vault.balanceOf account) - | .solidity => Solidity.shareBalances account - -end Implementation -end Contracts.Vault diff --git a/Contracts/Vault/Proofs/Execution.lean b/Contracts/Vault/Proofs/Execution.lean deleted file mode 100644 index 6d40d5ea29..0000000000 --- a/Contracts/Vault/Proofs/Execution.lean +++ /dev/null @@ -1,147 +0,0 @@ -import Contracts.Vault.Spec -import Contracts.Vault.Implementations - -namespace Contracts.Vault.Execution -open Verity -open Verity.Stdlib.Math - -/-- The same proof tactic unfolds either implementation; it does not assume -that the imported body is equal to the handwritten one. -/ -macro "reduce_vault" : tactic => `(tactic| - simp_all [Spec.deposit_execution, Spec.withdraw_execution, Spec.balance_execution, - Spec.accountingState, Implementation.deposit, Implementation.withdraw, - Implementation.balanceOf, Implementation.totalAssets, Implementation.totalSupply, - Implementation.shareBalances, nonpayableEntry, - Vault.deposit, Vault.withdraw, Vault.balanceOf, Vault.totalAssets, Vault.totalSupply, - Vault.totalAssetsSlot, Vault.totalSupplySlot, Vault.shareBalancesSlot, - Solidity.deposit, Solidity.withdraw, Solidity.balanceOf, Solidity.totalAssets, - Solidity.totalSupply, Solidity.shareBalances, - Solidity.totalAssetsSlot, Solidity.totalSupplySlot, Solidity.shareBalancesSlot, - Contract.run, Bind.bind, Pure.pure, Verity.instMonadContract, Verity.bind, Verity.pure, - msgValue, msgSender, Verity.require, revertCustomError, formatCustomError, getStorage, setStorage, getMapping, setMapping, - requireSomeUint, safeSub, Verity.EVM.Uint256.sub, Nat.not_le_of_lt, Nat.not_lt_of_ge, - ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, - ContractState.writeMap, ContractState.storage, ContractState.storageMap]) - -theorem balance_meets_spec (impl : Implementation) (s : ContractState) (account : Address) - (h0 : s.msgValue = 0) : Spec.balance_execution impl.balanceOf s account := by - cases impl <;> reduce_vault - -theorem deposit_meets_spec (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : - Spec.deposit_execution impl.deposit s amount := by - cases impl <;> reduce_vault - -theorem withdraw_meets_spec (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) - (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw_execution impl.withdraw s amount := by - cases impl <;> reduce_vault - rfl - -theorem deposit_nonpayable (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (impl.deposit amount).run s = ContractResult.revert "Nonpayable" s := by - cases impl <;> reduce_vault - -/-- A late failing addition rolls back the earlier mapping and asset writes. -/ -theorem deposit_late_overflow_rollback (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = none) : - (impl.deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by - cases impl <;> reduce_vault - -theorem withdraw_insufficient_shares (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : - (impl.withdraw amount).run s = ContractResult.revert "InsufficientShares()" s := by - cases impl <;> reduce_vault - -/-- Successful deposit changes no unrelated logical storage key. -/ -theorem deposit_frame (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) - (key : Verity.StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) - (hkm : key ≠ .map 2 s.sender) : - ((impl.deposit amount).run s).snd.storageWords key = s.storageWords key := by - have h := deposit_meets_spec impl s amount h0 hs ha ht - rw [Spec.deposit_execution] at h - rw [h] - simp [Spec.accountingState, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] - -theorem totalAssets_getter (impl : Implementation) (s : ContractState) (h0 : s.msgValue = 0) : - impl.totalAssets.run s = ContractResult.success (s.readSlot 0) s := by - cases impl <;> reduce_vault - -theorem totalSupply_getter (impl : Implementation) (s : ContractState) (h0 : s.msgValue = 0) : - impl.totalSupply.run s = ContractResult.success (s.readSlot 1) s := by - cases impl <;> reduce_vault - -theorem shareBalances_getter (impl : Implementation) (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : - (impl.shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by - cases impl <;> reduce_vault - -theorem withdraw_nonpayable (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (impl.withdraw amount).run s = ContractResult.revert "Nonpayable" s := by - cases impl <;> reduce_vault - -theorem withdraw_insufficient_assets (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : (s.readSlot 0).val < amount.val) : - (impl.withdraw amount).run s = ContractResult.revert "InsufficientAssets()" s := by - cases impl <;> reduce_vault - -theorem withdraw_insufficient_supply (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : - (impl.withdraw amount).run s = ContractResult.revert "InsufficientSupply()" s := by - cases impl <;> reduce_vault - -/-- Both implementations satisfy the pre-existing public deposit specification. -/ -theorem deposit_existing_spec (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : - Spec.deposit_spec amount s ((impl.deposit amount).run s).snd := by - have h := deposit_meets_spec impl s amount h0 hs ha ht - rw [Spec.deposit_execution] at h - rw [h] - simp +contextual [Spec.deposit_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, - Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, - Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, - Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, - ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, - ContractState.storage, ContractState.storageMap, ContractState.storageAddr, - Verity.EVM.Uint256.add] - repeat' constructor - all_goals exact Verity.Core.Uint256.add_comm _ _ - -/-- Both implementations satisfy the pre-existing public withdrawal specification. -/ -theorem withdraw_existing_spec (impl : Implementation) (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) - (ht : amount.val ≤ (s.readSlot 1).val) : - Spec.withdraw_spec amount s ((impl.withdraw amount).run s).snd := by - have h := withdraw_meets_spec impl s amount h0 hs ha ht - rw [Spec.withdraw_execution] at h - rw [h] - simp +contextual [Spec.withdraw_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, - Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, - Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, - Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, - ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, - ContractState.storage, ContractState.storageMap, ContractState.storageAddr, - Verity.EVM.Uint256.sub] - repeat' constructor - -end Contracts.Vault.Execution diff --git a/Contracts/Vault/README.md b/Contracts/Vault/README.md deleted file mode 100644 index 080c2a7279..0000000000 --- a/Contracts/Vault/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# One Vault, two implementations - -Choose how to write the contract; reuse the same specification and proof file. - -| File | Role | -| --- | --- | -| `Vault.lean` | Write the Vault directly with `verity_contract`. | -| `../../examples/solidity/Vault.sol` | Existing Solidity implementation, unchanged. | -| `Solidity.lean` | One `solidity_contract` declaration imports that Solidity file. | -| `Implementations.lean` | Select `.verity` or `.solidity` at the same typed external-call boundary. | -| `Spec.lean` | Shared requirements, including the original accounting specs. | -| `Proofs/Execution.lean` | One proof suite parameterized by the selected implementation. | - -For example, `Execution.deposit_existing_spec .verity` and -`Execution.deposit_existing_spec .solidity` are the **same theorem and proof**, -instantiated with different implementations. Neither branch assumes an unproved -correspondence; Lean checks both bodies. Existing `Proofs/Correctness.lean` -continues to check the original getter proofs. - -## Developer workflow - -1. Keep the contract in Solidity, or write it directly in Verity. -2. For Solidity, add the thin import declaration shown in `Solidity.lean`. -3. Write/reuse `Spec.lean`, then check the proofs with `lake build SolidityVault`. -4. After editing Solidity, rebuild and reload the Lean editor; live Solidity - watching is not implemented. See the root README for pinned-solc setup. - -The POC is registered for this Vault only, not arbitrary Solidity projects. - -Under the hood: `solc` resolves Solidity types/references; -`scripts/solidity_contract.py` validates its AST and prepares structured JSON; -`Verity/Solidity.lean` turns it into checked Verity definitions in memory. -The separate `scripts/check_solidity_contract.py` is the **maintainer test suite**, -not a second developer translation step. No generated model `.lean` or bytecode. - -## Matching behavior, not just names - -Verity already rejects ETH on nonpayable external calls in its compiler dispatcher -(`Compiler/CodegenCommon.lean`, `callvalueGuard`/`dispatchBody`). Its bare Lean -function definitions represent bodies. `Implementations.lean` exposes this guard -explicitly for shared proofs; the imported entrypoints already include it. -This adapter is not a proved full compiler-dispatch bridge. - -Both versions use matching withdrawal custom errors (`InsufficientShares()`, etc.) -and deposit write order. Shared proofs cover success, frame preservation, -nonpayability, withdrawal errors and late-overflow rollback, using the same -specification and hypotheses. Solidity's public mapping getter corresponds to -native `balanceOf` in the shared interface. Error labels and logical storage are -model representations; exact revert bytes, deployment and full Solidity/EVM -correspondence are not proved. The importer remains trusted. diff --git a/Contracts/Vault/Solidity.lean b/Contracts/Vault/Solidity.lean deleted file mode 100644 index 11477245a1..0000000000 --- a/Contracts/Vault/Solidity.lean +++ /dev/null @@ -1,7 +0,0 @@ -import Verity.Solidity - -namespace Contracts.Vault - -solidity_contract Solidity from "../../examples/solidity/Vault.sol" - -end Contracts.Vault diff --git a/Contracts/Vault/Spec.lean b/Contracts/Vault/Spec.lean index 9360e17608..2efe46b7d0 100644 --- a/Contracts/Vault/Spec.lean +++ b/Contracts/Vault/Spec.lean @@ -55,28 +55,4 @@ def withdraw_share_sum_equation (shares : Uint256) (s s' : ContractState) : Prop def assets_supply_synced (s : ContractState) : Prop := s.storage 0 = s.storage 1 -/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ -def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := - let mapped := { s.writeMap 2 s.sender shares with - knownAddresses := fun slotIdx => if slotIdx == 2 then - (s.knownAddresses slotIdx).insert s.sender else s.knownAddresses slotIdx } - (mapped.writeSlot 0 assets).writeSlot 1 supply - -def deposit_execution (deposit : Uint256 → Contract Unit) (s : ContractState) (amount : Uint256) : Prop := - (deposit amount).run s = ContractResult.success () - (accountingState s (s.readMap 2 s.sender + amount) - (s.readSlot 0 + amount) - (s.readSlot 1 + amount)) - -def withdraw_execution (withdraw : Uint256 → Contract Unit) (s : ContractState) (amount : Uint256) : Prop := - (withdraw amount).run s = ContractResult.success () - (accountingState s (s.readMap 2 s.sender - amount) - (s.readSlot 0 - amount) - (s.readSlot 1 - amount)) - -def balance_execution (balanceOf : Address → Contract Uint256) (s : ContractState) (account : Address) : Prop := - (balanceOf account).run s = - ContractResult.success (s.readMap 2 account) s - - end Contracts.Vault.Spec diff --git a/Contracts/Vault/Vault.lean b/Contracts/Vault/Vault.lean index 00e2eef8fa..9072c6fd7a 100644 --- a/Contracts/Vault/Vault.lean +++ b/Contracts/Vault/Vault.lean @@ -19,11 +19,6 @@ verity_contract Vault where totalSupplySlot : Uint256 := slot 1 shareBalancesSlot : Address → Uint256 := slot 2 - errors - error InsufficientShares() - error InsufficientAssets() - error InsufficientSupply() - constructor () := do setStorage totalAssetsSlot 0 setStorage totalSupplySlot 0 @@ -31,23 +26,23 @@ verity_contract Vault where function deposit (assets : Uint256) : Unit := do let sender ← msgSender let currentShares ← getMapping shareBalancesSlot sender - let newShares ← requireSomeUint (safeAdd currentShares assets) "Panic(0x11)" - setMapping shareBalancesSlot sender newShares + let newShares ← requireSomeUint (safeAdd currentShares assets) "Share balance overflow" let currentAssets ← getStorage totalAssetsSlot - let newAssets ← requireSomeUint (safeAdd currentAssets assets) "Panic(0x11)" - setStorage totalAssetsSlot newAssets + let newAssets ← requireSomeUint (safeAdd currentAssets assets) "Total assets overflow" let currentSupply ← getStorage totalSupplySlot - let newSupply ← requireSomeUint (safeAdd currentSupply assets) "Panic(0x11)" + let newSupply ← requireSomeUint (safeAdd currentSupply assets) "Total supply overflow" + setMapping shareBalancesSlot sender newShares + setStorage totalAssetsSlot newAssets setStorage totalSupplySlot newSupply function withdraw (shares : Uint256) : Unit := do let sender ← msgSender let currentShares ← getMapping shareBalancesSlot sender - requireError (currentShares >= shares) InsufficientShares() + require (currentShares >= shares) "Insufficient shares" let currentAssets ← getStorage totalAssetsSlot - requireError (currentAssets >= shares) InsufficientAssets() + require (currentAssets >= shares) "Insufficient assets" let currentSupply ← getStorage totalSupplySlot - requireError (currentSupply >= shares) InsufficientSupply() + require (currentSupply >= shares) "Insufficient supply" setMapping shareBalancesSlot sender (sub currentShares shares) setStorage totalAssetsSlot (sub currentAssets shares) setStorage totalSupplySlot (sub currentSupply shares) diff --git a/Verity/Solidity.lean b/Contracts/VaultFromSolidity/Importer/SolidityImporter.lean similarity index 97% rename from Verity/Solidity.lean rename to Contracts/VaultFromSolidity/Importer/SolidityImporter.lean index 70e6aa873d..5f6eac7ab4 100644 --- a/Verity/Solidity.lean +++ b/Contracts/VaultFromSolidity/Importer/SolidityImporter.lean @@ -1,7 +1,7 @@ import Lean import Verity.Stdlib.Math -/-! Proof-only, closed typed-AST importer. No source renderer or parse-back. -/ +/-! Proof-only, closed typed-AST importer for the Vault-from-Solidity example. -/ open Lean Meta Elab Command namespace SolidityImporter @@ -203,7 +203,8 @@ syntax (name := solidityContract) "solidity_contract " ident " from " str : comm let some p := root.parent | throwError "package root not found" if p == root then throwError "package root not found" root := p - let output ← IO.Process.output {cmd := "python3", args := #[(root / "scripts/solidity_contract.py").toString, source.toString]} + let frontend := root / "Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py" + let output ← IO.Process.output {cmd := "python3", args := #[frontend.toString, source.toString]} unless output.exitCode == 0 do throwError "Solidity import failed:\n{output.stderr}" let model ← match Json.parse output.stdout with | .ok j => pure j diff --git a/scripts/solidity_contract.py b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py similarity index 96% rename from scripts/solidity_contract.py rename to Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py index 743483f71f..f7beaf8ebf 100644 --- a/scripts/solidity_contract.py +++ b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py @@ -3,13 +3,17 @@ import hashlib import json import pathlib +import platform import re import subprocess import sys -ROOT = pathlib.Path(__file__).resolve().parents[1] -SOURCE = 'examples/solidity/Vault.sol' -PIN = '1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468' +ROOT = pathlib.Path(__file__).resolve().parents[4] +SOURCE = 'Contracts/VaultFromSolidity/Vault.sol' +PINS = { + 'Linux': '1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468', + 'Darwin': '8324280591ce398d7e2722846bc10ecf1779b13a328ef97b687c92cd9c70801a', +} SETTINGS = dict(optimizer={'enabled': False}, viaIR=False, evmVersion='cancun', remappings=[], outputSelection={'*': {'': ['ast'], '*': ['storageLayout']}}) @@ -167,6 +171,10 @@ def visit(n): def main(): + system = platform.system() + if system not in PINS: + raise ValueError(f'unsupported compiler platform: {system}') + pin = PINS[system] source = pathlib.Path(sys.argv[1]).resolve(strict=True) if not source.is_relative_to(ROOT.resolve(strict=True)): raise ValueError('source outside package') @@ -174,14 +182,14 @@ def main(): raise ValueError('unregistered source or source outside package') raw = source.read_bytes() binary = ROOT / '.lake/solidity-import/solc' - if digest(binary.read_bytes()) != PIN: + if digest(binary.read_bytes()) != pin: raise ValueError('compiler checksum mismatch') version = subprocess.check_output([str(binary), '--version']).decode() if '0.8.33+commit.64118f21.' not in version: raise ValueError('compiler version mismatch') inp = dict(language='Solidity', sources={SOURCE: {'content': raw.decode()}}, settings=SETTINGS) encoded = json.dumps(inp, sort_keys=True).encode() - key = digest(encoded + PIN.encode()) + key = digest(encoded + pin.encode()) cache = binary.parent / (key + '.json') if cache.exists(): record = json.loads(cache.read_text()) @@ -339,8 +347,8 @@ def statements(nodes, scope, returns): print(json.dumps(dict(fields=list(fields.values()), functions=output, digest=digest(json.dumps(dict(input=inp, output=out, pythonImporter=digest(pathlib.Path(__file__).read_bytes()), - leanImporter=digest((ROOT / 'Verity/Solidity.lean').read_bytes()), - compilerSha256=PIN, compilerVersion=version), sort_keys=True).encode())))) + leanImporter=digest((ROOT / 'Contracts/VaultFromSolidity/Importer/SolidityImporter.lean').read_bytes()), + compilerSha256=pin, compilerVersion=version), sort_keys=True).encode())))) if __name__ == '__main__': try: diff --git a/scripts/check_solidity_contract.py b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py similarity index 85% rename from scripts/check_solidity_contract.py rename to Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py index 657f5e0ec3..0857d28fe3 100644 --- a/scripts/check_solidity_contract.py +++ b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 -"""Focused proof-only Vault acceptance checks; all mutations are in a disposable copy. +"""Focused Vault-from-Solidity acceptance checks; mutations use a disposable copy. -Prerequisite: lake build SolidityVault and the pinned .lake/solidity-import/solc. +Prerequisite: lake build VaultFromSolidity and the pinned .lake/solidity-import/solc. Runs no bytecode compiler and writes no generated model Lean source. """ import hashlib import json import os from pathlib import Path +import platform import re import shutil import subprocess import tempfile -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[4] ENV = dict(os.environ, PATH=f"{Path.home()}/.elan/bin:{Path.home()}/.local/bin:" + os.environ['PATH']) @@ -42,10 +43,10 @@ def main(): for name in ('build', 'solidity-import'): shutil.copytree(ROOT / '.lake' / name, root / '.lake' / name) (root / '.lake/packages').symlink_to(ROOT / '.lake/packages', target_is_directory=True) - source = root / 'examples/solidity/Vault.sol' + source = root / 'Contracts/VaultFromSolidity/Vault.sol' original = source.read_bytes() stamp = source.stat() - frontend = root / 'scripts/solidity_contract.py' + frontend = root / 'Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py' frontend_original = frontend.read_bytes() compiler = root / '.lake/solidity-import/solc' compiler_original = compiler.read_bytes() @@ -54,26 +55,29 @@ def edit(data): source.write_bytes(data) os.utime(source, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) def build(success=True, contains=None): - return run(root, ['lake', 'build', 'SolidityVault'], success, contains) + return run(root, ['lake', 'build', 'VaultFromSolidity'], success, contains) def model(success=True, contains=None): return run(root, ['python3', str(frontend), str(source)], success, contains) def artifacts(): return {str(p.relative_to(root)): (p.stat().st_mtime_ns, hashlib.sha256(p.read_bytes()).hexdigest()) - for p in (root / '.lake/build/lib/lean/Contracts/Vault').rglob('*.olean')} + for p in (root / '.lake/build/lib/lean/Contracts/VaultFromSolidity').rglob('*.olean')} def caches(): return {p.name: (p.stat().st_mtime_ns, p.read_bytes()) for p in compiler.parent.glob('*.json')} build() - check(True, 'baseline lake build SolidityVault') - proof = root / 'Contracts/Vault/Proofs/Execution.lean' + check(True, 'baseline lake build VaultFromSolidity') + proof = root / 'Contracts/VaultFromSolidity/Proofs/Execution.lean' theorem_names = re.findall(r'^theorem\s+(\w+)', proof.read_text(), re.M) audit_file = root / '.lake/solidity-import/AxiomAudit.lean' try: - audit_file.write_text('import Contracts.Vault.Proofs.Execution\n' + - '\n'.join('#print axioms Contracts.Vault.Execution.' + name for name in theorem_names) + '\n') + audit_file.write_text('import Contracts.VaultFromSolidity.Proofs.Execution\n' + + '\n'.join('#print axioms Contracts.VaultFromSolidity.Proofs.Execution.' + name + for name in theorem_names) + '\n') audit = run(root, ['lake', 'env', 'lean', str(audit_file)]) finally: audit_file.unlink(missing_ok=True) - entries = re.findall(r"'Contracts.Vault.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", audit) + entries = re.findall( + r"'Contracts.VaultFromSolidity.Proofs.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", + audit) check(set(theorem_names) == {name for name, _ in entries}, 'every theorem appears in actual #print axioms output') axioms = {a.strip() for _, values in entries for a in values.split(',') if a.strip()} check(axioms <= {'propext', 'Quot.sound', 'Classical.choice'}, 'no project axioms or sorryAx: ' + ', '.join(sorted(axioms))) @@ -147,28 +151,12 @@ def need(ok, n, why): edit(original.replace(old, new)) changed_model = json.loads(model()) check(changed_model['functions'] != baseline_model['functions'], name + ' changes accepted AST behavior') - out = build(False, 'Contracts.Vault.Proofs.Execution') + out = build(False, 'Contracts.VaultFromSolidity.Proofs.Execution') check('unsolved goals' in out or 'Type mismatch' in out or 'type mismatch' in out, name + ' preserved-mtime source edit rebuilds and breaks existing proof') check(before != artifacts(), name + ' refreshes dependent oleans') edit(original) build() - # Both branches really participate in the shared proof; the entry adapter - # cannot silently conceal a native payable declaration. - native = root / 'Contracts/Vault/Vault.lean' - native_original = native.read_bytes() - try: - native.write_bytes(native_original.replace(b'InsufficientShares', b'NotEnoughShares')) - build(False, 'Contracts.Vault.Proofs.Execution') - check(True, 'native custom-error mutation breaks the same shared proof') - native.write_bytes(native_original) - build() - native.write_bytes(native_original.replace(b'function deposit', b'function payable deposit')) - build(False, 'did not evaluate to `true`') - check(True, 'native payable mutation rejected by entry-boundary metadata check') - finally: - native.write_bytes(native_original) - build() for name, old, new, diagnostic in ( ('contract layout at', b'contract Vault {', b'contract Vault layout at 100 {', 'layout at'), ('initializer', b'uint256 public totalAssets;', b'uint256 public totalAssets = 1;', 'initializer'), @@ -179,7 +167,7 @@ def need(ok, n, why): ): edit(original.replace(old, new)) output = model(False, diagnostic) - check(re.search(r'examples/solidity/Vault.sol:\d+:\d+:', output) is not None, + check(re.search(r'Contracts/VaultFromSolidity/Vault.sol:\d+:\d+:', output) is not None, name + ' rejected with source location') # Exercise an import failure through Lake as well as the frontend. build(False, 'unsupported binary') @@ -193,7 +181,7 @@ def need(ok, n, why): check(json.loads(model())['digest'] != baseline_model['digest'], 'importer change updates sourceDigest') frontend.write_bytes(frontend_original) build() - lean_importer = root / 'Verity/Solidity.lean' + lean_importer = root / 'Contracts/VaultFromSolidity/Importer/SolidityImporter.lean' lean_original = lean_importer.read_bytes() try: lean_importer.write_bytes(lean_original + b'\n-- acceptance translation identity probe\n') @@ -230,33 +218,33 @@ def need(ok, n, why): # Authored diagnostic snippets, not generated semantic/model source. probe_file = root / '.lake/solidity-import/RegistrationProbe.lean' try: - probe_file.write_text('''import Contracts.Vault.Solidity + probe_file.write_text('''import Contracts.VaultFromSolidity.VaultFromSolidity open Lean Elab Command run_cmd do for suffix in ["totalAssetsSlot", "totalSupplySlot", "shareBalancesSlot", "deposit", "withdraw", "balanceOf", "totalAssets", "totalSupply", "shareBalances", "sourceDigest"] do - let name := `Contracts.Vault.Solidity ++ Name.mkSimple suffix + let name := `Contracts.VaultFromSolidity ++ Name.mkSimple suffix let some (.defnInfo info) := (← getEnv).find? name | throwError "not a transparent definition: {name}" unless info.safety == .safe && !info.value.hasMVar && !info.value.hasFVar do throwError "unsafe or unclosed definition: {name}" for dep in info.value.getUsedConstants do if dep.toString.startsWith "Contracts." && - !dep.toString.startsWith "Contracts.Vault.Solidity." then + !dep.toString.startsWith "Contracts.VaultFromSolidity." then throwError "imported declaration depends on handwritten contract: {dep}" - let some (.defnInfo deposit) := (← getEnv).find? `Contracts.Vault.Solidity.deposit + let some (.defnInfo deposit) := (← getEnv).find? `Contracts.VaultFromSolidity.deposit | throwError "missing imported deposit" for dep in [``Verity.setMapping, ``Verity.setStorage, ``Verity.Stdlib.Math.safeAdd] do unless deposit.value.getUsedConstants.contains dep do throwError "missing source-derived deposit operation: {dep}" logInfo "CHECKED_TRANSPARENT_DECLARATIONS" -solidity_contract Existing from "../../examples/solidity/Vault.sol" +solidity_contract Existing from "../../Contracts/VaultFromSolidity/Vault.sol" run_cmd do let original ← getEnv let mut rejected := false try - SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Existing):ident from "../../examples/solidity/Vault.sol")) + SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Existing):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) catch _ => rejected := true unless rejected do throwError "duplicate alias accepted" let some (.defnInfo before) := original.find? `Existing.deposit @@ -278,14 +266,14 @@ def need(ok, n, why): lean_importer.write_bytes(lean_original.replace( b' type := type\n', b' type := if name.toString.endsWith ".deposit" then mkConst ``Nat else type\n')) - run(root, ['lake', 'build', 'SolidityFrontend']) - probe_file.write_text('''import Verity.Solidity + run(root, ['lake', 'build', 'VaultSolidityImporter']) + probe_file.write_text('''import Contracts.VaultFromSolidity.Importer.SolidityImporter open Lean Elab Command set_option Elab.async true run_cmd do let mut rejected := false try - SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Broken):ident from "../../examples/solidity/Vault.sol")) + SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Broken):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) catch e => rejected := true logInfo m!"EXPECTED_KERNEL_ERROR {e.toMessageData}" @@ -320,12 +308,17 @@ def need(ok, n, why): for path in compiler.parent.glob('*.json'): path.unlink() # Force only the imported wrapper to elaborate again, keeping prerequisites. - for path in (root / '.lake/build/lib/lean/Contracts/Vault').glob('Solidity.*'): + for path in (root / '.lake/build/lib/lean/Contracts/VaultFromSolidity').glob('VaultFromSolidity.*'): path.unlink() - run(root, ['strace', '-f', '-e', 'inject=socket:error=EPERM', '-o', - str(root / '.lake/solidity-import/offline.trace'), - 'lake', 'build', 'SolidityVault']) - check(bool(caches()), 'cold AST cache and current-source Lake build succeed with new network sockets denied') + if platform.system() == 'Linux': + run(root, ['strace', '-f', '-e', 'inject=socket:error=EPERM', '-o', + str(root / '.lake/solidity-import/offline.trace'), + 'lake', 'build', 'VaultFromSolidity']) + check(True, 'Linux cold-cache build succeeds with new network sockets denied') + else: + build() + check(True, 'cold-cache build succeeds (socket-denial probe is Linux-only)') + check(bool(caches()), 'cold AST cache and current-source Lake build succeed') check(set(root.rglob('*.lean')) == lean_sources, 'no generated model .lean files') check(source.read_bytes() == original and frontend.read_bytes() == frontend_original, 'temporary source and importer restored; final baseline build passes') diff --git a/Contracts/VaultFromSolidity/Proofs/Execution.lean b/Contracts/VaultFromSolidity/Proofs/Execution.lean new file mode 100644 index 0000000000..3819bdcdd2 --- /dev/null +++ b/Contracts/VaultFromSolidity/Proofs/Execution.lean @@ -0,0 +1,136 @@ +import Contracts.VaultFromSolidity.Spec + +namespace Contracts.VaultFromSolidity.Proofs.Execution +open Verity +open Verity.Stdlib.Math + +macro "reduce_vault" : tactic => `(tactic| + simp_all [Spec.deposit_execution, Spec.withdraw_execution, Spec.balance_execution, + Spec.accountingState, deposit, withdraw, balanceOf, totalAssets, totalSupply, + shareBalances, totalAssetsSlot, totalSupplySlot, shareBalancesSlot, + Contract.run, Bind.bind, Pure.pure, Verity.instMonadContract, Verity.bind, Verity.pure, + msgValue, msgSender, Verity.require, + getStorage, setStorage, getMapping, setMapping, requireSomeUint, safeSub, + Verity.EVM.Uint256.sub, Nat.not_le_of_lt, Nat.not_lt_of_ge, + ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, + ContractState.writeMap, ContractState.storage, ContractState.storageMap]) + +theorem balance_meets_spec (s : ContractState) (account : Address) + (h0 : s.msgValue = 0) : Spec.balance_execution s account := by + reduce_vault + +theorem deposit_meets_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : + Spec.deposit_execution s amount := by + reduce_vault + +theorem withdraw_meets_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) + (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw_execution s amount := by + reduce_vault + +theorem deposit_nonpayable (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (deposit amount).run s = ContractResult.revert "Nonpayable" s := by + reduce_vault + +/-- A late failing addition rolls back the earlier mapping and asset writes. -/ +theorem deposit_late_overflow_rollback (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = none) : + (deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by + reduce_vault + +theorem withdraw_insufficient_shares (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : + (withdraw amount).run s = ContractResult.revert "InsufficientShares()" s := by + reduce_vault + +/-- Successful deposit changes no unrelated logical storage key. -/ +theorem deposit_frame (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) + (key : Verity.StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) + (hkm : key ≠ .map 2 s.sender) : + ((deposit amount).run s).snd.storageWords key = s.storageWords key := by + have h := deposit_meets_spec s amount h0 hs ha ht + rw [Spec.deposit_execution] at h + rw [h] + simp [Spec.accountingState, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] + +theorem totalAssets_getter (s : ContractState) (h0 : s.msgValue = 0) : + totalAssets.run s = ContractResult.success (s.readSlot 0) s := by + reduce_vault + +theorem totalSupply_getter (s : ContractState) (h0 : s.msgValue = 0) : + totalSupply.run s = ContractResult.success (s.readSlot 1) s := by + reduce_vault + +theorem shareBalances_getter (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : + (shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by + reduce_vault + +theorem withdraw_nonpayable (s : ContractState) (amount : Uint256) + (h0 : s.msgValue.val ≠ 0) : + (withdraw amount).run s = ContractResult.revert "Nonpayable" s := by + reduce_vault + +theorem withdraw_insufficient_assets (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : (s.readSlot 0).val < amount.val) : + (withdraw amount).run s = ContractResult.revert "InsufficientAssets()" s := by + reduce_vault + +theorem withdraw_insufficient_supply (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : + (withdraw amount).run s = ContractResult.revert "InsufficientSupply()" s := by + reduce_vault + +theorem deposit_existing_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) + (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) + (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : + Spec.deposit_spec amount s ((deposit amount).run s).snd := by + have h := deposit_meets_spec s amount h0 hs ha ht + rw [Spec.deposit_execution] at h + rw [h] + simp +contextual [Spec.deposit_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, + Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, + Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, + Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, + ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage, ContractState.storageMap, + Verity.EVM.Uint256.add] + repeat' constructor + all_goals exact Verity.Core.Uint256.add_comm _ _ + +theorem withdraw_existing_spec (s : ContractState) (amount : Uint256) + (h0 : s.msgValue = 0) + (hs : amount.val ≤ (s.readMap 2 s.sender).val) + (ha : amount.val ≤ (s.readSlot 0).val) + (ht : amount.val ≤ (s.readSlot 1).val) : + Spec.withdraw_spec amount s ((withdraw amount).run s).snd := by + have h := withdraw_meets_spec s amount h0 hs ha ht + rw [Spec.withdraw_execution] at h + rw [h] + simp +contextual [Spec.withdraw_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, + Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, + Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, + Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, + ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage, ContractState.storageMap, + Verity.EVM.Uint256.sub] + repeat' constructor + +end Contracts.VaultFromSolidity.Proofs.Execution diff --git a/Contracts/VaultFromSolidity/README.md b/Contracts/VaultFromSolidity/README.md new file mode 100644 index 0000000000..ac63c7bb81 --- /dev/null +++ b/Contracts/VaultFromSolidity/README.md @@ -0,0 +1,36 @@ +# Vault from Solidity + +This self-contained example imports an existing Solidity contract into Lean and +proves properties directly about the imported definitions. It does not depend on +the handwritten `Contracts/Vault` example. + +## File map + +| File | Role | +| --- | --- | +| `Vault.sol` | The original Solidity implementation. | +| `Importer/scripts/solidity_importer.py` | Runs pinned solc, validates the supported AST and emits structured JSON. | +| `Importer/SolidityImporter.lean` | Implements `solidity_contract` and registers checked Verity definitions in Lean. | +| `VaultFromSolidity.lean` | Points the importer at `Vault.sol`. | +| `Spec.lean` | Human-written requirements for the imported contract. | +| `Proofs/Execution.lean` | Proofs that the imported contract satisfies those requirements. | +| `Importer/scripts/solidity_importer_test.py` | Maintainer acceptance tests; not a developer translation step. | + +## Developer workflow + +1. Keep or edit `Vault.sol`. +2. Declare its import in `VaultFromSolidity.lean`. +3. Write the required behavior in `Spec.lean`. +4. Prove it in `Proofs/Execution.lean`. +5. Run `lake build VaultFromSolidity` and reload the Lean editor after Solidity changes. + +The importer asks pinned solc for the typed AST and storage layout. The Python +frontend rejects unsupported constructs and emits a small JSON model. The Lean +importer converts that model into transparent, kernel-checked `Verity.Contract` +definitions directly in memory. It does not generate model `.lean` files or +bytecode. + +The current proof of concept accepts only this registered Vault and a deliberately +small Solidity subset. The importer remains a trusted translation boundary: the +Lean kernel proves the stated properties of the imported definitions, not a +general Solidity-to-Verity or bytecode equivalence theorem. diff --git a/Contracts/VaultFromSolidity/Spec.lean b/Contracts/VaultFromSolidity/Spec.lean new file mode 100644 index 0000000000..765be020e9 --- /dev/null +++ b/Contracts/VaultFromSolidity/Spec.lean @@ -0,0 +1,57 @@ +import Verity.Specs.Common +import Verity.Specs.Common.Sum +import Verity.EVM.Uint256 +import Contracts.VaultFromSolidity.VaultFromSolidity + +namespace Contracts.VaultFromSolidity.Spec + +open Verity +open Verity.Specs +open Verity.EVM.Uint256 + +def storageUnchangedExceptAssetSlots (s s' : ContractState) : Prop := + ∀ slotIdx : Nat, slotIdx ≠ 0 → slotIdx ≠ 1 → s'.storage slotIdx = s.storage slotIdx + +def sameStorageExceptAssetSlots (s s' : ContractState) : Prop := + storageUnchangedExceptAssetSlots s s' ∧ + Specs.sameStorageAddr s s' ∧ + Specs.sameContext s s' + +def deposit_spec (assets : Uint256) (s s' : ContractState) : Prop := + s'.storageMap 2 s.sender = add (s.storageMap 2 s.sender) assets ∧ + s'.storage 0 = add (s.storage 0) assets ∧ + s'.storage 1 = add (s.storage 1) assets ∧ + Specs.storageMapUnchangedExceptKeyAtSlot 2 s.sender s s' ∧ + sameStorageExceptAssetSlots s s' + +def withdraw_spec (shares : Uint256) (s s' : ContractState) : Prop := + s'.storageMap 2 s.sender = sub (s.storageMap 2 s.sender) shares ∧ + s'.storage 0 = sub (s.storage 0) shares ∧ + s'.storage 1 = sub (s.storage 1) shares ∧ + Specs.storageMapUnchangedExceptKeyAtSlot 2 s.sender s s' ∧ + sameStorageExceptAssetSlots s s' + +/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ +def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := + let mapped := { s.writeMap 2 s.sender shares with + knownAddresses := fun slotIdx => if slotIdx == 2 then + (s.knownAddresses slotIdx).insert s.sender else s.knownAddresses slotIdx } + (mapped.writeSlot 0 assets).writeSlot 1 supply + +def deposit_execution (s : ContractState) (amount : Uint256) : Prop := + (Contracts.VaultFromSolidity.deposit amount).run s = ContractResult.success () + (accountingState s (s.readMap 2 s.sender + amount) + (s.readSlot 0 + amount) + (s.readSlot 1 + amount)) + +def withdraw_execution (s : ContractState) (amount : Uint256) : Prop := + (Contracts.VaultFromSolidity.withdraw amount).run s = ContractResult.success () + (accountingState s (s.readMap 2 s.sender - amount) + (s.readSlot 0 - amount) + (s.readSlot 1 - amount)) + +def balance_execution (s : ContractState) (account : Address) : Prop := + (Contracts.VaultFromSolidity.balanceOf account).run s = + ContractResult.success (s.readMap 2 account) s + +end Contracts.VaultFromSolidity.Spec diff --git a/examples/solidity/Vault.sol b/Contracts/VaultFromSolidity/Vault.sol similarity index 94% rename from examples/solidity/Vault.sol rename to Contracts/VaultFromSolidity/Vault.sol index 2100748310..577aa14120 100644 --- a/examples/solidity/Vault.sol +++ b/Contracts/VaultFromSolidity/Vault.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.33; /// @title Vault /// @notice Minimal ERC4626-style vault with 1:1 asset/share accounting. -/// @dev Reference implementation matching `Contracts/Vault/Vault.lean`. +/// @dev Source contract for the proof-only Solidity importer example. contract Vault { uint256 public totalAssets; uint256 public totalSupply; diff --git a/Contracts/VaultFromSolidity/VaultFromSolidity.lean b/Contracts/VaultFromSolidity/VaultFromSolidity.lean new file mode 100644 index 0000000000..21bd5b70bd --- /dev/null +++ b/Contracts/VaultFromSolidity/VaultFromSolidity.lean @@ -0,0 +1,7 @@ +import Contracts.VaultFromSolidity.Importer.SolidityImporter + +namespace Contracts + +solidity_contract VaultFromSolidity from "Vault.sol" + +end Contracts diff --git a/PrintAxioms.lean b/PrintAxioms.lean index c9a263c6da..17e1b29a49 100644 --- a/PrintAxioms.lean +++ b/PrintAxioms.lean @@ -34,8 +34,8 @@ import Contracts.SimpleToken.Proofs.Correctness import Contracts.SimpleToken.Proofs.Isolation import Contracts.SimpleToken.Proofs.Supply import Contracts.Vault.Proofs.Correctness -import Contracts.Vault.Proofs.Execution import Contracts.Vault.Proofs.Native +import Contracts.VaultFromSolidity.Proofs.Execution import Verity.Proofs.CheckedExternalCallConsumer import Verity.Proofs.LoopSimulationResultAware import Verity.Proofs.Model.CommonExternalCallEquivalence @@ -683,28 +683,28 @@ end Verity.AxiomAudit Contracts.Vault.Proofs.balanceOf_meets_spec Contracts.Vault.Proofs.balanceOf_preserves_state - -- Contracts/Vault/Proofs/Execution.lean - Contracts.Vault.Execution.balance_meets_spec - Contracts.Vault.Execution.deposit_meets_spec - Contracts.Vault.Execution.withdraw_meets_spec - Contracts.Vault.Execution.deposit_nonpayable - Contracts.Vault.Execution.deposit_late_overflow_rollback - Contracts.Vault.Execution.withdraw_insufficient_shares - Contracts.Vault.Execution.deposit_frame - Contracts.Vault.Execution.totalAssets_getter - Contracts.Vault.Execution.totalSupply_getter - Contracts.Vault.Execution.shareBalances_getter - Contracts.Vault.Execution.withdraw_nonpayable - Contracts.Vault.Execution.withdraw_insufficient_assets - Contracts.Vault.Execution.withdraw_insufficient_supply - Contracts.Vault.Execution.deposit_existing_spec - Contracts.Vault.Execution.withdraw_existing_spec - -- Contracts/Vault/Proofs/Native.lean Contracts.Vault.Proofs.Native.vaultMinimal_functions_bridged Contracts.Vault.Proofs.Native.vaultMinimal_runtime_lowers_native Contracts.Vault.Proofs.Native.vaultMinimal_totalAssets_nativeResultsMatchOn_revert_of_nonzero_value + -- Contracts/VaultFromSolidity/Proofs/Execution.lean + Contracts.VaultFromSolidity.Proofs.Execution.balance_meets_spec + Contracts.VaultFromSolidity.Proofs.Execution.deposit_meets_spec + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_meets_spec + Contracts.VaultFromSolidity.Proofs.Execution.deposit_nonpayable + Contracts.VaultFromSolidity.Proofs.Execution.deposit_late_overflow_rollback + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_shares + Contracts.VaultFromSolidity.Proofs.Execution.deposit_frame + Contracts.VaultFromSolidity.Proofs.Execution.totalAssets_getter + Contracts.VaultFromSolidity.Proofs.Execution.totalSupply_getter + Contracts.VaultFromSolidity.Proofs.Execution.shareBalances_getter + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_nonpayable + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_assets + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_supply + Contracts.VaultFromSolidity.Proofs.Execution.deposit_existing_spec + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_existing_spec + -- Verity/Proofs/CheckedExternalCallConsumer.lean Verity.Proofs.CheckedExternalCallConsumer.lido_submit_entry_installs_caller_context Verity.Proofs.CheckedExternalCallConsumer.lido_submit_success_world diff --git a/README.md b/README.md index 72b6ea36c8..661d0beae8 100644 --- a/README.md +++ b/README.md @@ -23,29 +23,32 @@ ## Proof-only Solidity Vault import (POC) -`Contracts/Vault/Solidity.lean` imports the existing -`examples/solidity/Vault.sol` with `solidity_contract Solidity from -"../../examples/solidity/Vault.sol"`. The frontend requests typed AST and storage +`Contracts/VaultFromSolidity/VaultFromSolidity.lean` imports the colocated +`Vault.sol` with `solidity_contract VaultFromSolidity from "Vault.sol"`. +The frontend requests typed AST and storage layout from pinned solc 0.8.33, then registers transparent, kernel-checked `Verity.Contract` definitions directly in memory. There is no generated model -`.lean`, CompilationModel, or bytecode. Both the handwritten and imported Vault -use the same `Contracts/Vault/Spec.lean` and `Proofs/Execution.lean`. -See [Vault's two-implementation walkthrough](Contracts/Vault/README.md). +`.lean`, CompilationModel, or bytecode. The example is independent of the +handwritten `Contracts/Vault` contract. See the +[Vault-from-Solidity walkthrough](Contracts/VaultFromSolidity/README.md). -With the Lean/package prerequisites installed, put the pinned Linux solc binary -at `.lake/solidity-import/solc` (executable; SHA-256 -`1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468`), then run: +With the Lean/package prerequisites installed, put the pinned solc 0.8.33 binary +at `.lake/solidity-import/solc` and make it executable. Accepted official SHA-256 +digests are `1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468` +for Linux amd64 and +`8324280591ce398d7e2722846bc10ecf1779b13a328ef97b687c92cd9c70801a` +for macOS amd64, then run: ```sh -lake build SolidityVault -python3 scripts/check_solidity_contract.py +lake build VaultFromSolidity +python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py ``` The acceptance script uses disposable copies for source mutations, rejection, content-based Lake freshness, cache reuse, compiler/importer invalidation, and an audit of every Vault theorem. It also tests declaration-registration rollback -and cold-cache builds with new sockets denied (the test runner requires Linux -`strace`; normal imports do not). It never mutates the original Solidity file. +and cold-cache builds. On Linux it also denies new sockets with `strace`; normal +imports do not require `strace`. It never mutates the original Solidity file. Save Solidity, rebuild this dedicated target, then reload the Lean editor: an already-open editor snapshot does not automatically watch `.sol` changes. See [the trust boundary](TRUST_ASSUMPTIONS.md#proof-only-solidity-vault-import). diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index b19162ba68..fa884bec8a 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -5,8 +5,9 @@ This document states what Verity proves and what it still trusts. ## Proof-only Solidity Vault import This POC is separate from the verified compilation pipeline below. It trusts -pinned solc's typed AST/storage layout, `scripts/solidity_contract.py` validation, -and `Verity/Solidity.lean` translation to preserve Solidity meaning. Kernel +pinned solc's typed AST/storage layout, the colocated Python frontend, and +`Contracts/VaultFromSolidity/Importer/SolidityImporter.lean` translation to +preserve Solidity meaning. Kernel checking establishes well-typed definitions and theorems about their execution, not a Solidity-to-Verity equivalence theorem. `sourceDigest` is provenance, not proof of correspondence. It hashes the compiler input/output, Python frontend, @@ -35,20 +36,16 @@ or full EVM equivalence claim. Initial states are arbitrary, not proven deployed states. Arithmetic success premises restrict success theorems; separate failure proofs cover nonpayability, insufficient balances and late-overflow rollback. -Both implementations use the shared Vault specification and execution proof file. -`Implementations.lean` explicitly wraps native function bodies with the nonpayable -entry check already present in `Compiler/CodegenCommon.lean:dispatchBody`; imported -entrypoints already contain this guard. This is a proof-facing entry adapter, not -a new compiler rule or a theorem bridging the wrapper to deployed dispatch. -Zero-argument custom errors use Verity's `Name()` model convention. The native -Vault declares typed withdrawal errors; arithmetic panic strings remain a model -representation, not an assertion of matching EVM revert bytes. Native deposit -write order follows the Solidity source. The shared statements do not assert -full equivalence of all executions or all public/deployment interfaces. - -Lake's dedicated `SolidityVault` target tracks source/compiler/Python-and-Lean-importer/build +The specification and execution proof file refer directly to the imported +definitions. Zero-argument custom errors use Verity's `Name()` model convention; +arithmetic panic strings remain a model representation, not an assertion of +matching EVM revert bytes. The statements do not assert full equivalence of all +executions or all public/deployment interfaces. + +Lake's dedicated `VaultFromSolidity` target tracks source/compiler/Python-and-Lean-importer/build policy bytes and normal Lean dependencies. Acceptance evidence is obtained with -`python3 scripts/check_solidity_contract.py`; stale editor snapshots are not a +`python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py`; +stale editor snapshots are not a current-source proof certificate. No additional project axiom is introduced. ## Compilation Pipeline diff --git a/artifacts/trust_surface_report.json b/artifacts/trust_surface_report.json index ef14f60e7c..a724e456a0 100644 --- a/artifacts/trust_surface_report.json +++ b/artifacts/trust_surface_report.json @@ -169,7 +169,7 @@ "mechanisms": { "@[implemented_by": 1, "native_decide": 584, - "partial def": 175 + "partial def": 178 }, "notes": "native_decide trusts Lean.ofReduceBool or Lean 4.31 generated per-proof native_decide axioms + Lean.trustCompiler. Prose registry: AXIOMS.md, TRUST_ASSUMPTIONS.md (enforced by scripts/check_trust_surface_registry.py).", "schema_version": 1 diff --git a/artifacts/verification_status.json b/artifacts/verification_status.json index 3ed6aeae48..0d856ccb20 100644 --- a/artifacts/verification_status.json +++ b/artifacts/verification_status.json @@ -1,7 +1,7 @@ { "codebase": { "core_lines": 2040, - "example_contracts": 18 + "example_contracts": 19 }, "proofs": { "axioms": 1, @@ -15,11 +15,11 @@ "suites": 52 }, "theorems": { - "categories": 15, - "coverage_percent": 78, + "categories": 16, + "coverage_percent": 74, "covered": 255, - "excluded": 74, - "non_stdlib_total": 329, + "excluded": 89, + "non_stdlib_total": 344, "per_contract": { "Counter": 31, "ERC20": 22, @@ -35,11 +35,12 @@ "SafeCounter": 25, "SimpleStorage": 20, "SimpleToken": 61, - "Vault": 9 + "Vault": 9, + "VaultFromSolidity": 15 }, - "proven": 329, + "proven": 344, "stdlib": 0, - "total": 329 + "total": 344 }, "toolchain": { "lean": "leanprover/lean4:v4.31.0", diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index b4b89b6647..df35f72363 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -31,9 +31,9 @@ Every transition inside the proof envelope is either fully verified or recorded - **Language**: Lean 4.31.0 -- **Core Size**: 1991 lines -- **Verified Contracts**: 15 (Counter, ERC20, ERC721, Ledger, LocalObligationMacroSmoke, Ownable, Owned, OwnedCounter, OwnedCounterComposed, ReentrancyExample, ReentrancyRelyGuarantee, SafeCounter, SimpleStorage, SimpleToken, Vault) -- **Theorems**: 329 across 15 categories, 329 fully proven +- **Core Size**: 2040 lines +- **Verified Contracts**: 16 (Counter, ERC20, ERC721, Ledger, LocalObligationMacroSmoke, Ownable, Owned, OwnedCounter, OwnedCounterComposed, ReentrancyExample, ReentrancyRelyGuarantee, SafeCounter, SimpleStorage, SimpleToken, Vault, VaultFromSolidity) +- **Theorems**: 344 across 16 categories, 344 fully proven - **Axioms**: 1 documented Lean axioms (see AXIOMS.md) - **Tests**: 528 Foundry tests, 239 property tests - **Build**: `lake build` verifies all proofs diff --git a/docs/VERIFICATION_STATUS.md b/docs/VERIFICATION_STATUS.md index 08f4684387..08f23c7278 100644 --- a/docs/VERIFICATION_STATUS.md +++ b/docs/VERIFICATION_STATUS.md @@ -39,12 +39,13 @@ EVM Bytecode | ERC20 | 22 | Baseline | `Contracts/ERC20/Proofs/` | | ERC721 | 11 | Baseline | `Contracts/ERC721/Proofs/` | | Vault | 9 | Baseline | `Contracts/Vault/Proofs/` | +| VaultFromSolidity | 15 | Proof-only import | `Contracts/VaultFromSolidity/Proofs/` | | ReentrancyExample | 5 | Complete | `Contracts/ReentrancyExample/Contract.lean` | | ReentrancyRelyGuarantee | 10 | Semantic | `Contracts/ReentrancyRelyGuarantee/Contract.lean` | | CryptoHash | 0 | No specs | `Contracts/CryptoHash/Contract.lean` | -| **Total** | **329** | **✅ 100%** | — | +| **Total** | **344** | **✅ 100%** | — | -> **Note**: Stdlib (0 internal proof-automation properties) is excluded from the contract-spec theorem table above but included in overall coverage statistics (329 total properties). +> **Note**: Stdlib (0 internal proof-automation properties) is excluded from the contract-spec theorem table above but included in overall coverage statistics (344 total properties). Layer 1 uses macro-generated EDSL-to-`CompilationModel` bridge theorems backed by a generic typed-IR compilation-correctness theorem ([`TypedIRCompilerCorrectness.lean`](../Compiler/TypedIRCompilerCorrectness.lean)). Tuple/bytes/fixed-array/dynamic-array/string parameters now stay inside that proof path when they are carried as ABI head words/offsets. Advanced constructs beyond that typed-IR head-word surface (linked libraries, ECMs, fully custom ABI behavior) are still expressed directly in `CompilationModel` and trusted at that boundary. Higher-order internal helpers (function-pointer parameters, [#1747](https://github.com/lfglabs-dev/verity/issues/1747)) are eliminated by a compile-time monomorphization pre-pass that runs before any lowering, so the `CompilationModel` only ever contains first-order helpers: these calls are covered by the existing first-order proof path and introduce no new boundary trust. @@ -202,6 +203,7 @@ Also note that the macro-generated `*_semantic_preservation` theorems are not co |----------|----------|------------| | ERC20 | 86% (19/22) | 3 proof-only | | Vault | 0% (0/9) | 9 proof-only | +| VaultFromSolidity | 0% (0/15) | 15 proof-only | | ERC721 | 100% (11/11) | 0 | | SafeCounter | 100% (25/25) | 0 | | ReentrancyExample | 100% (5/5) | 0 | @@ -217,13 +219,13 @@ Also note that the macro-generated `*_semantic_preservation` theorems are not co | Counter | 74% (23/31) | 8 proof-only | | Stdlib | 0% (0/0) | 0 proof-only | -**Status**: 78% coverage (255/329), 74 remaining exclusions all proof-only +**Status**: 74% coverage (255/344), 89 remaining exclusions all proof-only -- **Total Properties**: 329 +- **Total Properties**: 344 - **Covered**: 255 -- **Excluded**: 74 (all proof-only) +- **Excluded**: 89 (all proof-only) -**Proof-Only Properties (59 exclusions)**: Internal proof machinery that cannot be tested in Foundry. +**Proof-Only Properties (74 exclusions)**: Internal proof machinery that cannot be tested in Foundry. 0 `sorry` remaining across `Compiler/**/*.lean` and `Verity/**/*.lean` proof modules. 5266 theorems/lemmas (3645 public, 1621 private) verified by `lake build PrintAxioms`. diff --git a/lakefile.lean b/lakefile.lean index 01b5afd122..e6b0ced724 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -23,15 +23,15 @@ lean_lib «Verity» where ] input_file vaultSolidity where - path := "examples/solidity/Vault.sol" + path := "Contracts/VaultFromSolidity/Vault.sol" text := false input_file vaultFrontend where - path := "scripts/solidity_contract.py" + path := "Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py" text := false input_file vaultLeanImporter where - path := "Verity/Solidity.lean" + path := "Contracts/VaultFromSolidity/Importer/SolidityImporter.lean" text := false input_file vaultSolc where @@ -42,12 +42,13 @@ input_file vaultBuildPolicy where path := "lakefile.lean" text := false -lean_lib «SolidityFrontend» where - globs := #[.one `Verity.Solidity] +lean_lib «VaultSolidityImporter» where + globs := #[.one `Contracts.VaultFromSolidity.Importer.SolidityImporter] -lean_lib «SolidityVault» where - globs := #[.one `Contracts.Vault.Solidity, .one `Contracts.Vault.Implementations, - .one `Contracts.Vault.Proofs.Execution] +lean_lib «VaultFromSolidity» where + globs := #[.one `Contracts.VaultFromSolidity.VaultFromSolidity, + .one `Contracts.VaultFromSolidity.Spec, + .one `Contracts.VaultFromSolidity.Proofs.Execution] needs := #[vaultSolidity, vaultFrontend, vaultLeanImporter, vaultSolc, vaultBuildPolicy] lean_lib «Contracts» where diff --git a/scripts/check_contract_structure.py b/scripts/check_contract_structure.py index 9a063a23a0..ddf5428da3 100755 --- a/scripts/check_contract_structure.py +++ b/scripts/check_contract_structure.py @@ -21,6 +21,7 @@ "ReentrancyRelyGuarantee", # Proof-only rely-guarantee framework example, inline proofs "Ownable", # Mixin facet: named-slot proofs + footprint, no Foundry/Yul twin "OwnedCounterComposed", # Include-host acceptance example; OwnedCounter keeps Yul/difftest + "VaultFromSolidity", # Proof-only imported model with its own focused structure } # Contracts excluded from property test check @@ -30,6 +31,7 @@ "ReentrancyRelyGuarantee", # Abstract state-transformer proofs, no compiled contract to property-test "Ownable", # Mixin proofs are reused by hosts; no compiled property harness "OwnedCounterComposed", # Proof-composition host; OwnedCounter remains the Foundry target + "VaultFromSolidity", # Imported-model theorems are covered by the focused mutation suite } # Contracts excluded from differential test check @@ -40,6 +42,7 @@ "ReentrancyRelyGuarantee", # No compiled bytecode (abstract proofs), nothing to differential-test "Ownable", # Mixin facet; no dedicated Yul/Foundry twin "OwnedCounterComposed", # Selectors/layout stay on OwnedCounter until bit-identical + "VaultFromSolidity", # Proof-only importer emits no bytecode for differential testing } # Expected files for each contract (relative to ROOT) diff --git a/test/Vault.t.sol b/test/Vault.t.sol index b7d9941b78..c221dd2761 100644 --- a/test/Vault.t.sol +++ b/test/Vault.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.33; import "forge-std/Test.sol"; -import "../examples/solidity/Vault.sol"; +import "../Contracts/VaultFromSolidity/Vault.sol"; contract VaultTest is Test { Vault internal vault; diff --git a/test/property_exclusions.json b/test/property_exclusions.json index b02ea2d277..d687ae8cf7 100644 --- a/test/property_exclusions.json +++ b/test/property_exclusions.json @@ -15,6 +15,23 @@ "vaultMinimal_runtime_lowers_native", "vaultMinimal_totalAssets_nativeResultsMatchOn_revert_of_nonzero_value" ], + "VaultFromSolidity": [ + "balance_meets_spec", + "deposit_existing_spec", + "deposit_frame", + "deposit_late_overflow_rollback", + "deposit_meets_spec", + "deposit_nonpayable", + "shareBalances_getter", + "totalAssets_getter", + "totalSupply_getter", + "withdraw_existing_spec", + "withdraw_insufficient_assets", + "withdraw_insufficient_shares", + "withdraw_insufficient_supply", + "withdraw_meets_spec", + "withdraw_nonpayable" + ], "Counter": [ "getStorage_reads_count", "previewAddTwice_correct", diff --git a/test/property_manifest.json b/test/property_manifest.json index b43566798a..63a0db7013 100644 --- a/test/property_manifest.json +++ b/test/property_manifest.json @@ -357,5 +357,22 @@ "vaultMinimal_functions_bridged", "vaultMinimal_runtime_lowers_native", "vaultMinimal_totalAssets_nativeResultsMatchOn_revert_of_nonzero_value" + ], + "VaultFromSolidity": [ + "balance_meets_spec", + "deposit_existing_spec", + "deposit_frame", + "deposit_late_overflow_rollback", + "deposit_meets_spec", + "deposit_nonpayable", + "shareBalances_getter", + "totalAssets_getter", + "totalSupply_getter", + "withdraw_existing_spec", + "withdraw_insufficient_assets", + "withdraw_insufficient_shares", + "withdraw_insufficient_supply", + "withdraw_meets_spec", + "withdraw_nonpayable" ] } From a9bc3feb585965d9dd090cc617b7bb316b26ecd7 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 10 Sep 2026 19:49:35 +0200 Subject: [PATCH 7/8] refactor: move Solidity frontend entirely into Lean --- .github/actions/setup-solc/action.yml | 16 +- .github/workflows/verify.yml | 5 + AUDIT.md | 32 +- .../VaultFromSolidity/Importer/Importer.lean | 849 ++++++++++++++++++ .../Importer/SolidityImporter.lean | 220 ----- .../Importer/scripts/solidity_importer.py | 358 -------- .../scripts/solidity_importer_test.py | 574 +++++++----- Contracts/VaultFromSolidity/README.md | 12 +- .../VaultFromSolidity/VaultFromSolidity.lean | 2 +- README.md | 28 +- TRUST_ASSUMPTIONS.md | 52 +- artifacts/trust_surface_report.json | 2 +- lakefile.lean | 10 +- 13 files changed, 1273 insertions(+), 887 deletions(-) create mode 100644 Contracts/VaultFromSolidity/Importer/Importer.lean delete mode 100644 Contracts/VaultFromSolidity/Importer/SolidityImporter.lean delete mode 100644 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py diff --git a/.github/actions/setup-solc/action.yml b/.github/actions/setup-solc/action.yml index e29fe6fcc1..81dfc668ce 100644 --- a/.github/actions/setup-solc/action.yml +++ b/.github/actions/setup-solc/action.yml @@ -1,6 +1,12 @@ name: Setup solc description: Cache and install the Solidity compiler +inputs: + destination: + description: Optional repository-local path at which to copy the verified binary + required: false + default: '' + runs: using: composite steps: @@ -46,4 +52,12 @@ runs: - name: Verify solc shell: bash - run: solc --version + run: | + solc --version + destination='${{ inputs.destination }}' + if [ -n "$destination" ]; then + mkdir -p "$(dirname "$destination")" + cp "$(command -v solc)" "$destination" + chmod +x "$destination" + echo "${SOLC_SHA256} ${destination}" | sha256sum -c - + fi diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1cd8b104b8..b7c50a54d9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -391,6 +391,11 @@ jobs: disable-lake-cache-restore: ${{ env.VERIFY_DISABLE_LAKE_CACHE_RESTORE }} cache-primary-key: lake-${{ runner.os }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('lakefile.lean') }}-${{ hashFiles('lake-manifest.json') }}-${{ github.run_id }} + - name: Setup pinned solc for Lean Solidity importer + uses: ./.github/actions/setup-solc + with: + destination: .lake/solidity-import/solc + - name: Rebuild cached local Lean modules run: | rm -rf .lake/build/lib/lean/Verity .lake/build/ir/Verity diff --git a/AUDIT.md b/AUDIT.md index ce13b82137..da36459b12 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -7,29 +7,31 @@ boundary checks change. ## Proof-only Solidity Vault POC -The focused suite also probes recursive AST rejection (including metadata), -contract `layout at`, registered-source symlink escape, and Lean importer digest -sensitivity. It also checks safe transparent declarations, duplicate aliases, -a deliberately malformed late declaration and complete registration rollback, -and a cold-cache build, with new network sockets denied using strace on Linux. -The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not -a transitive build identity. +The focused suite probes unknown, wrong-typed, and missing AST fields (including +documentation metadata), invalid source spans, and malformed storage layout +through synthetic compiler-output mutations, plus unsupported source constructs, +contract `layout at`, +registered-source symlink escape, and Lean importer digest sensitivity. It also +checks safe transparent declarations, duplicate aliases, a deliberately +malformed late declaration and complete registration rollback, plus the pinned +compiler's checksum. The digest scope is documented in `TRUST_ASSUMPTIONS.md`; it is not a transitive build identity. Evidence command: `python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py` (after `lake build VaultFromSolidity` and installation of the pinned compiler). The focused runner builds and audits the imported execution proofs, changes -accepted deposit/getter behavior while preserving source mtime and requires -old proofs to fail, rejects unsupported source, checks unchanged artifacts/cache, -and exercises Python-importer and compiler content invalidation. Mutations occur -only in disposable copies. This is local acceptance evidence, not a new CI job, +accepted deposit/getter behavior while preserving source mtime and requires old +proofs to fail, rejects unsupported source, checks unchanged artifacts, and +exercises Lean-importer and compiler content invalidation. Mutations occur only +in disposable copies. This is local acceptance evidence, not a new CI job, bytecode/runtime test, or proof of translation correctness. The complete example surface lives under `Contracts/VaultFromSolidity`: Solidity -source, Python and Lean importers, specification, execution proofs and focused -acceptance tests. It is independent of the handwritten `Contracts/Vault` example. -No generated model source or bytecode is emitted. Trust and axiom scope are -recorded in `TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. +source, Lean importer, specification, execution proofs and focused acceptance +tests. It is independent of the handwritten `Contracts/Vault` example. No +Python frontend, custom serialized IR, generated Lean source, or bytecode is in +the translation path. Trust and axiom scope are recorded in +`TRUST_ASSUMPTIONS.md` and `AXIOMS.md`. ## Current Audit State diff --git a/Contracts/VaultFromSolidity/Importer/Importer.lean b/Contracts/VaultFromSolidity/Importer/Importer.lean new file mode 100644 index 0000000000..121bfc808f --- /dev/null +++ b/Contracts/VaultFromSolidity/Importer/Importer.lean @@ -0,0 +1,849 @@ +import Lean +import Verity.Stdlib.Math +import Compiler.Sha256.Engine + +/-! +A proof-only Solidity frontend. This module invokes pinned `solc --standard-json`, +validates a closed typed-AST/storage-layout subset, and directly registers safe, +transparent Verity definitions. It emits neither an intermediate IR nor Lean source. +-/ + +open Lean Meta Elab Command + +namespace SolidityImporter + +private def solcVersionOutput := + "solc, the solidity compiler commandline interface\nVersion: 0.8.33+commit.64118f21.Linux.g++" +private def solcSha256 := "1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468" +private def registeredSource := "Contracts/VaultFromSolidity/Vault.sol" + +private def field (j : Json) (key : String) : MetaM Json := + match j.getObjVal? key with + | .ok v => pure v + | .error e => throwError "{e}" + +private def field? (j : Json) (key : String) : Option Json := + (j.getObjVal? key).toOption + +private def str (j : Json) : MetaM String := + match j.getStr? with + | .ok v => pure v + | .error e => throwError "{e}" + +private def nat (j : Json) : MetaM Nat := + match j.getNat? with + | .ok v => pure v + | .error e => throwError "{e}" + +private def int (j : Json) : MetaM Int := + match j.getInt? with + | .ok v => pure v + | .error e => throwError "{e}" + +private def bool (j : Json) : MetaM Bool := + match j.getBool? with + | .ok v => pure v + | .error e => throwError "{e}" + +private def arr (j : Json) : MetaM (Array Json) := + match j.getArr? with + | .ok v => pure v + | .error e => throwError "{e}" + +private def objKeys (j : Json) : MetaM (List String) := + match j with + | .obj o => pure <| o.foldl (fun keys key _ => key :: keys) [] + | _ => throwError "object expected" + +private def requireKeys (j : Json) (allowed : List String) (what : String) : MetaM Unit := do + for key in ← objKeys j do + unless key ∈ allowed do throwError "unknown {what} field {key}" + +private def nodeKind (j : Json) : MetaM String := field j "nodeType" >>= str +private def nodeId (j : Json) : MetaM Nat := field j "id" >>= nat + +private partial def collectAstIds (j : Json) : MetaM (List Nat) := do + match j with + | .obj o => + let self ← if (field? j "nodeType").isSome then do pure [← nodeId j] else pure [] + let mut ids := self + for (_, value) in o.toList do ids := ids ++ (← collectAstIds value) + pure ids + | .arr xs => + let mut ids := [] + for value in xs do ids := ids ++ (← collectAstIds value) + pure ids + | _ => pure [] + +private def expect (ok : Bool) (message : String) : MetaM Unit := + unless ok do throwError message + +private def hexDigit (n : Nat) : Char := + if n < 10 then Char.ofNat ('0'.toNat + n) else Char.ofNat ('a'.toNat + n - 10) + +private def sha256Hex (bytes : ByteArray) : String := + (Sha256Engine.sha256 bytes).data.foldl (init := "") fun acc byte => + acc.push (hexDigit (byte.toNat / 16)) |>.push (hexDigit (byte.toNat % 16)) + +private def verifyCompiler (compiler : System.FilePath) : MetaM Unit := do + let output ← IO.Process.output { cmd := "/usr/bin/sha256sum", args := #[compiler.toString] } + unless output.exitCode == 0 && (output.stdout.take 64).toString == solcSha256 do + throwError "compiler checksum mismatch" + +private structure SourceContext where + path : System.FilePath + logicalPath : String + bytes : ByteArray + sourceId : Nat + +private def parseSpan (j : Json) : MetaM (Nat × Nat × Nat) := do + let pieces := (← str (← field j "src")).splitOn ":" + match pieces with + | [a, b, c] => + let some start := a.toNat? | throwError "invalid source span" + let some size := b.toNat? | throwError "invalid source span" + let some sourceId := c.toNat? | throwError "invalid source span" + pure (start, size, sourceId) + | _ => throwError "invalid source span" + +private def failAt (ctx : SourceContext) (j : Json) (why : String) : MetaM α := do + let (start, size, _sourceId) ← parseSpan j + let before := ctx.bytes.data.extract 0 (min start ctx.bytes.size) + let (line, column) := before.foldl + (fun (p : Nat × Nat) b => if b == 10 then (p.1 + 1, 1) else (p.1, p.2 + 1)) (1, 1) + let excerptBytes : ByteArray := ⟨ctx.bytes.data.extract start (min (start + size) (min ctx.bytes.size (start + 100)))⟩ + let excerpt := String.fromUTF8? excerptBytes |>.getD "" + let kind := (← nodeKind j) + throwError "{ctx.logicalPath}:{line}:{column}: {kind}: {why}\n{excerpt}" + +private def needAt (ctx : SourceContext) (j : Json) (ok : Bool) (why : String) : MetaM Unit := + unless ok do failAt ctx j why + +private def commonFields := ["id", "src", "nodeType"] +private def expressionFields := ["isConstant", "isLValue", "isPure", "lValueRequested", "typeDescriptions"] + +private def allowedNodeFields : String → Option (List String) + | "SourceUnit" => some ["absolutePath", "exportedSymbols", "license", "nodes"] + | "PragmaDirective" => some ["literals"] + | "ContractDefinition" => some ["abstract", "baseContracts", "canonicalName", "contractDependencies", + "contractKind", "documentation", "fullyImplemented", "linearizedBaseContracts", "name", "nameLocation", + "nodes", "scope", "usedErrors", "usedEvents", "storageLayout"] + | "StructuredDocumentation" => some ["text"] + | "VariableDeclaration" => some ["constant", "functionSelector", "mutability", "name", "nameLocation", "scope", + "stateVariable", "storageLocation", "typeDescriptions", "typeName", "visibility", "value"] + | "ElementaryTypeName" => some ["name", "stateMutability", "typeDescriptions"] + | "Mapping" => some ["keyName", "keyNameLocation", "keyType", "typeDescriptions", "valueName", + "valueNameLocation", "valueType"] + | "ErrorDefinition" => some ["errorSelector", "name", "nameLocation", "parameters"] + | "FunctionDefinition" => some ["body", "functionSelector", "implemented", "kind", "modifiers", "name", + "nameLocation", "parameters", "returnParameters", "scope", "stateMutability", "virtual", "visibility", + "documentation"] + | "ParameterList" => some ["parameters"] + | "Block" => some ["statements"] + | "ExpressionStatement" => some ["expression"] + | "Assignment" => some (expressionFields ++ ["leftHandSide", "operator", "rightHandSide"]) + | "BinaryOperation" => some (expressionFields ++ ["commonType", "leftExpression", "operator", "rightExpression", "function"]) + | "Identifier" => some ["argumentTypes", "name", "overloadedDeclarations", "referencedDeclaration", "typeDescriptions"] + | "MemberAccess" => some (expressionFields ++ ["expression", "memberLocation", "memberName"]) + | "IndexAccess" => some (expressionFields ++ ["baseExpression", "indexExpression"]) + | "Literal" => some (expressionFields ++ ["hexValue", "kind", "subdenomination", "value"]) + | "FunctionCall" => some (expressionFields ++ ["arguments", "expression", "kind", "nameLocations", "names", "tryCall"]) + | "VariableDeclarationStatement" => some ["assignments", "declarations", "initialValue"] + | "IfStatement" => some ["condition", "trueBody", "falseBody"] + | "RevertStatement" => some ["errorCall"] + | "Return" => some ["expression", "functionReturnParameters"] + | _ => none + +private def requiredNodeFields : String → List String + | "SourceUnit" => ["absolutePath", "exportedSymbols", "license", "nodes"] + | "PragmaDirective" => ["literals"] + | "ContractDefinition" => ["abstract", "baseContracts", "canonicalName", "contractDependencies", + "contractKind", "fullyImplemented", "linearizedBaseContracts", "name", "nameLocation", "nodes", + "scope", "usedErrors", "usedEvents"] + | "StructuredDocumentation" => ["text"] + | "VariableDeclaration" => ["constant", "mutability", "name", "nameLocation", "scope", "stateVariable", + "storageLocation", "typeDescriptions", "typeName", "visibility"] + | "ElementaryTypeName" => ["name", "typeDescriptions"] + | "Mapping" => ["keyName", "keyNameLocation", "keyType", "typeDescriptions", "valueName", + "valueNameLocation", "valueType"] + | "ErrorDefinition" => ["errorSelector", "name", "nameLocation", "parameters"] + | "FunctionDefinition" => ["body", "functionSelector", "implemented", "kind", "modifiers", "name", + "nameLocation", "parameters", "returnParameters", "scope", "stateMutability", "virtual", "visibility"] + | "ParameterList" => ["parameters"] + | "Block" => ["statements"] + | "ExpressionStatement" => ["expression"] + | "Assignment" => expressionFields ++ ["leftHandSide", "operator", "rightHandSide"] + | "BinaryOperation" => expressionFields ++ ["commonType", "leftExpression", "operator", "rightExpression"] + | "Identifier" => ["name", "overloadedDeclarations", "referencedDeclaration", "typeDescriptions"] + | "MemberAccess" => expressionFields ++ ["expression", "memberLocation", "memberName", "typeDescriptions"] + | "IndexAccess" => expressionFields ++ ["baseExpression", "indexExpression", "typeDescriptions"] + | "Literal" => expressionFields ++ ["hexValue", "kind", "value", "typeDescriptions"] + | "FunctionCall" => expressionFields ++ ["arguments", "expression", "kind", "nameLocations", "names", + "tryCall", "typeDescriptions"] + | "VariableDeclarationStatement" => ["assignments", "declarations", "initialValue"] + | "IfStatement" => ["condition", "trueBody"] + | "RevertStatement" => ["errorCall"] + | "Return" => ["expression", "functionReturnParameters"] + | _ => [] + +private def childFields : List String := ["nodes", "baseContracts", "parameters", "returnParameters", "body", + "statements", "typeName", "keyType", "valueType", "modifiers", "overrides", "storageLayout", "leftHandSide", + "rightHandSide", "leftExpression", "rightExpression", "expression", "baseExpression", "indexExpression", + "arguments", "declarations", "initialValue", "condition", "trueBody", "falseBody", "errorCall"] + +private def validateTypeDescription (j : Json) : MetaM Unit := do + requireKeys j ["typeIdentifier", "typeString"] "type description" + let _ ← str (← field j "typeIdentifier") + let _ ← str (← field j "typeString") + +private def validateStringArray (j : Json) : MetaM Unit := do + for value in ← arr j do let _ ← str value + +private def validateNatArray (j : Json) : MetaM Unit := do + for value in ← arr j do let _ ← nat value + +private def validateAssignments (j : Json) : MetaM Unit := do + for value in ← arr j do unless value.isNull do let _ ← nat value + +private def validateExportedSymbols (j : Json) : MetaM Unit := do + match j with + | .obj entries => for (_, ids) in entries.toList do validateNatArray ids + | _ => throwError "expected exported-symbol object" + +private def validateMetadataField (ctx : SourceContext) (node : Json) (kind key : String) (value : Json) : MetaM Unit := do + if ["absolutePath", "canonicalName", "contractKind", "text", "mutability", "name", + "nameLocation", "scope", "storageLocation", "visibility", "stateMutability", "keyName", + "keyNameLocation", "valueName", "valueNameLocation", "errorSelector", "kind", "operator", + "memberLocation", "memberName", "hexValue", "value"].contains key then + if key == "scope" then let _ ← nat value + else let _ ← str value + else if ["abstract", "fullyImplemented", "constant", "stateVariable", "indexed", "implemented", + "virtual", "isConstant", "isLValue", "isPure", "lValueRequested", "tryCall"].contains key then + let _ ← bool value + else if key == "referencedDeclaration" then + let _ ← int value + else if key == "functionReturnParameters" then + let _ ← nat value + else if ["contractDependencies", "linearizedBaseContracts", "usedErrors", "usedEvents", + "baseFunctions", "overloadedDeclarations"].contains key then + validateNatArray value + else if ["literals", "nameLocations", "names"].contains key then + validateStringArray value + else if key == "assignments" then validateAssignments value + else if key == "exportedSymbols" then validateExportedSymbols value + else if ["license", "functionSelector", "subdenomination"].contains key then + unless value.isNull do let _ ← str value + else + failAt ctx node ("unvalidated AST metadata field " ++ kind ++ "." ++ key) + +private partial def validateNode (ctx : SourceContext) (j : Json) : MetaM Unit := do + let kind ← nodeKind j + let some allowed := allowedNodeFields kind | failAt ctx j "unsupported AST node" + let keys ← objKeys j + needAt ctx j (keys.all fun key => commonFields.contains key || allowed.contains key) + ("unexpected AST fields: " ++ String.intercalate ", " (keys.filter fun k => !(commonFields.contains k || allowed.contains k))) + let missing := (requiredNodeFields kind).filter fun key => !(keys.contains key) + needAt ctx j missing.isEmpty ("missing AST fields: " ++ String.intercalate ", " missing) + let _ ← nodeId j + let (start, size, sourceId) ← parseSpan j + needAt ctx j (sourceId == ctx.sourceId && start <= ctx.bytes.size && size <= ctx.bytes.size - start) + "source span outside registered source" + if let some description := field? j "typeDescriptions" then + validateTypeDescription description + if let some common := field? j "commonType" then + unless common.isNull do validateTypeDescription common + if let some arguments := field? j "argumentTypes" then + unless arguments.isNull do + for description in ← arr arguments do validateTypeDescription description + if let some documentation := field? j "documentation" then + if documentation.isNull then pure () + else match documentation with + | .str _ => pure () + | .obj _ => + needAt ctx j ((← nodeKind documentation) == "StructuredDocumentation") "invalid documentation" + validateNode ctx documentation + | _ => failAt ctx j "invalid documentation" + for key in keys do + unless commonFields.contains key || childFields.contains key || + ["typeDescriptions", "commonType", "argumentTypes", "documentation"].contains key || + (kind == "VariableDeclaration" && key == "value") do + try validateMetadataField ctx j kind key (← field j key) + catch _ => failAt ctx j ("invalid AST metadata field " ++ kind ++ "." ++ key) + if kind == "ContractDefinition" then + needAt ctx j ((field? j "storageLayout").all Json.isNull) "contract layout at specifier unsupported" + if kind == "VariableDeclaration" then + needAt ctx j ((field? j "value").all Json.isNull && (field? j "overrides").all Json.isNull) + "initializer/override unsupported" + if kind == "BinaryOperation" then + needAt ctx j ((field? j "function").all Json.isNull) "user-defined operator unsupported" + if kind == "FunctionCall" then + needAt ctx j ((← str (← field j "kind")) == "functionCall" && !(← bool (← field j "tryCall")) && + (← arr (← field j "names")).isEmpty) "unsupported call surface" + for key in childFields do + if let some value := field? j key then + if key == "storageLayout" && kind == "ContractDefinition" then pure () + else if value.isNull then pure () + else match value with + | .arr values => for child in values do validateNode ctx child + | .obj _ => validateNode ctx value + | _ => failAt ctx j ("invalid AST child: " ++ key) + let requireKind (key : String) (kinds : List String) : MetaM Unit := do + let child ← field j key + needAt ctx j (kinds.contains (← nodeKind child)) ("unexpected child kind: " ++ key) + match kind with + | "SourceUnit" => + let _ ← arr (← field j "nodes") + | "ContractDefinition" => + let _ ← arr (← field j "nodes"); let _ ← arr (← field j "baseContracts") + | "FunctionDefinition" => + requireKind "body" ["Block"] + requireKind "parameters" ["ParameterList"] + requireKind "returnParameters" ["ParameterList"] + let _ ← arr (← field j "modifiers") + | "ParameterList" => + for p in (← arr (← field j "parameters")) do + needAt ctx j ((← nodeKind p) == "VariableDeclaration") "invalid parameter declaration" + | "Block" => let _ ← arr (← field j "statements") + | "VariableDeclaration" => requireKind "typeName" ["ElementaryTypeName", "Mapping"] + | "Mapping" => requireKind "keyType" ["ElementaryTypeName"]; requireKind "valueType" ["ElementaryTypeName"] + | "ExpressionStatement" => let _ ← field j "expression" + | "Assignment" => let _ ← field j "leftHandSide"; let _ ← field j "rightHandSide" + | "BinaryOperation" => let _ ← field j "leftExpression"; let _ ← field j "rightExpression" + | "MemberAccess" => let _ ← field j "expression" + | "IndexAccess" => let _ ← field j "baseExpression"; let _ ← field j "indexExpression" + | "FunctionCall" => let _ ← field j "expression"; let _ ← arr (← field j "arguments") + | "VariableDeclarationStatement" => + for d in (← arr (← field j "declarations")) do + needAt ctx j ((← nodeKind d) == "VariableDeclaration") "invalid local declaration" + let _ ← field j "initialValue" + | "IfStatement" => requireKind "trueBody" ["Block"] + | "RevertStatement" => requireKind "errorCall" ["FunctionCall"] + | "Return" => let _ ← field j "expression" + | "ErrorDefinition" => requireKind "parameters" ["ParameterList"] + | _ => pure () + +private structure FieldInfo where + id : Nat + name : String + getter : Option String + slot : Nat + mapping : Bool + +private structure Frontend where + source : SourceContext + ast : Json + fields : List FieldInfo + errors : List (Nat × String) + functions : List Json + digest : String + +private def validName (name : String) : Bool := + match name.toList with + | [] => false + | c :: cs => c.isAlpha && cs.all (fun c => c.isAlphanum || c == '_') && name != "sourceDigest" + +private def identifier (ctx : SourceContext) (j : Json) : MetaM String := do + let name ← str (← field j "name") + needAt ctx j (validName name) "unsupported/reserved name" + pure name + +private def typeString (j : Json) : MetaM String := + field j "typeDescriptions" >>= (field · "typeString") >>= str + +set_option maxRecDepth 2048 in +private def parseCompilerOutput (sourcePath : System.FilePath) (logicalPath : String) + (raw : ByteArray) (outputText version importerText : String) : MetaM Frontend := do + let output ← match Json.parse outputText with + | .ok j => pure j + | .error e => throwError "invalid solc standard JSON: {e}" + requireKeys output ["contracts", "sources", "errors"] "solc output" + if let some errors := field? output "errors" then + for e in (← arr errors) do + requireKeys e ["component", "errorCode", "formattedMessage", "message", "severity", + "sourceLocation", "type"] "compiler diagnostic" + if (← str (← field e "severity")) == "error" then + throwError "{← str (← field e "formattedMessage")}" + let sources ← field output "sources" + let sourceKeys ← objKeys sources + expect (sourceKeys == [logicalPath]) "unexpected compiler sources" + let sourceOut ← field sources logicalPath + requireKeys sourceOut ["ast", "id"] "source output" + let sourceId ← nat (← field sourceOut "id") + let ast ← field sourceOut "ast" + let ctx := { path := sourcePath, logicalPath, bytes := raw, sourceId } + needAt ctx ast ((← nodeKind ast) == "SourceUnit") "root must be SourceUnit" + validateNode ctx ast + needAt ctx ast ((← str (← field ast "absolutePath")) == logicalPath) + "source-unit path mismatch" + let ids ← collectAstIds ast + needAt ctx ast (ids.length == ids.eraseDups.length) "duplicate Solidity AST node ID" + let mut contracts : List Json := [] + for node in (← arr (← field ast "nodes")) do + match ← nodeKind node with + | "PragmaDirective" => + let literals ← arr (← field node "literals") + needAt ctx node (literals.size == 4 && (← str literals[0]!) == "solidity" && + (← str literals[1]!) == "^" && (← str literals[2]!) == "0.8" && + (← str literals[3]!) == ".33") "unsupported pragma" + | "ContractDefinition" => contracts := node :: contracts + | _ => failAt ctx node "unsupported source declaration" + needAt ctx ast (contracts.length == 1) "exactly one concrete contract required" + let contract := contracts.head! + let contractId ← nodeId contract + needAt ctx contract ((← str (← field contract "contractKind")) == "contract" && + !(← bool (← field contract "abstract")) && (← bool (← field contract "fullyImplemented")) && + (← arr (← field contract "baseContracts")).isEmpty && + (← arr (← field contract "contractDependencies")).isEmpty && + (← arr (← field contract "usedEvents")).isEmpty) + "inheritance/abstract contract unsupported" + let linearized ← arr (← field contract "linearizedBaseContracts") + needAt ctx contract (linearized.size == 1 && (← nat linearized[0]!) == contractId) + "invalid contract linearization" + let contractName ← str (← field contract "name") + needAt ctx contract ((← str (← field contract "canonicalName")) == contractName && + (← nat (← field contract "scope")) == (← nodeId ast)) "contract identity mismatch" + let exports ← field ast "exportedSymbols" + needAt ctx ast ((← objKeys exports) == [contractName]) "exported symbol mismatch" + let exportedIds ← arr (← field exports contractName) + needAt ctx ast (exportedIds.size == 1 && (← nat exportedIds[0]!) == contractId) + "exported symbol mismatch" + let compilerContracts ← field output "contracts" + expect ((← objKeys compilerContracts) == [logicalPath]) "unexpected compiler contract sources" + let sourceContracts ← field compilerContracts logicalPath + expect ((← objKeys sourceContracts) == [contractName]) "unexpected compiler contracts" + let contractOut ← field sourceContracts contractName + requireKeys contractOut ["storageLayout"] "contract output" + let layout ← field contractOut "storageLayout" + requireKeys layout ["storage", "types"] "storage layout" + let storage ← arr (← field layout "storage") + let layoutTypes ← field layout "types" + let layoutTypeKeys ← objKeys layoutTypes + expect (layoutTypeKeys.length == 3 && layoutTypeKeys.all fun key => + ["t_address", "t_uint256", "t_mapping(t_address,t_uint256)"].contains key) + "unexpected storage type table" + let mut fields : List FieldInfo := [] + let mut errors : List (Nat × String) := [] + let mut functions : List Json := [] + for node in (← arr (← field contract "nodes")) do + match ← nodeKind node with + | "VariableDeclaration" => + let name ← identifier ctx node + needAt ctx node ((← bool (← field node "stateVariable")) && !(← bool (← field node "constant")) && + (← str (← field node "mutability")) == "mutable" && (field? node "value").all Json.isNull && + (← str (← field node "storageLocation")) == "default" && + (← nat (← field node "scope")) == contractId) "initializer/constant/transient field unsupported" + let typ ← typeString node + needAt ctx node (typ == "uint256" || typ == "mapping(address => uint256)") "unsupported storage type" + let id ← nodeId node + let some entry := storage.find? fun e => (field? e "astId").bind (·.getNat?.toOption) == some id + | failAt ctx node "missing storage layout" + requireKeys entry ["astId", "contract", "label", "offset", "slot", "type"] "storage entry" + needAt ctx node ((← str (← field entry "contract")) == s!"{logicalPath}:{contractName}" && + (← str (← field entry "label")) == name) "storage declaration mismatch" + needAt ctx node ((← nat (← field entry "offset")) == 0) "missing/packed layout" + let typeId ← str (← field entry "type") + let layoutType ← field layoutTypes typeId + let expectedTypeKeys := if typ == "uint256" then + ["encoding", "label", "numberOfBytes"] + else ["encoding", "key", "label", "numberOfBytes", "value"] + requireKeys layoutType expectedTypeKeys "storage type" + needAt ctx node ((← str (← field layoutType "numberOfBytes")) == "32") "nonword layout" + if typ == "uint256" then + needAt ctx node ((← str (← field layoutType "encoding")) == "inplace" && + (← str (← field layoutType "label")) == typ) "bad scalar layout" + else + let keyType ← field layoutTypes (← str (← field layoutType "key")) + let valueType ← field layoutTypes (← str (← field layoutType "value")) + requireKeys keyType ["encoding", "label", "numberOfBytes"] "mapping key type" + requireKeys valueType ["encoding", "label", "numberOfBytes"] "mapping value type" + let layoutEncoding ← str (← field layoutType "encoding") + let keyEncoding ← str (← field keyType "encoding") + let keyLabel ← str (← field keyType "label") + let keyBytes ← str (← field keyType "numberOfBytes") + let valueEncoding ← str (← field valueType "encoding") + let valueLabel ← str (← field valueType "label") + let valueBytes ← str (← field valueType "numberOfBytes") + needAt ctx node (layoutEncoding == "mapping" && keyEncoding == "inplace" && + keyLabel == "address" && keyBytes == "20" && valueEncoding == "inplace" && + valueLabel == "uint256" && valueBytes == "32") "bad mapping layout" + let getter := if (← str (← field node "visibility")) == "public" then some name else none + let slotText ← str (← field entry "slot") + let some slot := slotText.toNat? | failAt ctx node "invalid storage slot" + fields := FieldInfo.mk id (name ++ "Slot") getter slot (typ.startsWith "mapping") :: fields + | "ErrorDefinition" => + let ps ← arr (← field (← field node "parameters") "parameters") + needAt ctx node ps.isEmpty "only zero-argument custom errors" + errors := ((← nodeId node), (← identifier ctx node)) :: errors + | "FunctionDefinition" => + needAt ctx node ((← nat (← field node "scope")) == contractId) "function scope mismatch" + functions := node :: functions + | _ => failAt ctx node "unsupported contract declaration" + needAt ctx contract (storage.size == fields.length && storage.all fun e => + (field? e "astId").bind (·.getNat?.toOption) |>.any fun id => fields.any (·.id == id)) "unaccounted layout field" + let names := fields.flatMap fun f => f.name :: f.getter.toList + needAt ctx contract (names.length == names.eraseDups.length) "storage/generated name collision" + let slots := fields.map (·.slot) + needAt ctx contract (slots.length == slots.eraseDups.length) "storage slot collision" + let usedErrors ← arr (← field contract "usedErrors") + let mut usedErrorIds : List Nat := [] + for errorId in usedErrors do usedErrorIds := (← nat errorId) :: usedErrorIds + let errorIds := errors.map (·.1) + needAt ctx contract (usedErrorIds.length == errorIds.length && + usedErrorIds.all fun id => errorIds.contains id) "custom error reference mismatch" + let sourceText := String.fromUTF8? raw |>.getD "" + let digest := sha256Hex (sourceText ++ outputText ++ importerText ++ solcSha256 ++ version).toUTF8 + pure <| Frontend.mk ctx ast fields.reverse errors functions.reverse digest + +private def uint := mkConst ``Verity.Core.Uint256 +private def address := mkConst ``Verity.Core.Address +private def unit := mkConst ``Unit +private def valueType (s : String) : MetaM Expr := + match s with + | "uint256" => pure uint + | "address" => pure address + | "unit" => pure unit + | _ => throwError "unsupported type {s}" + +private def ret (x : Expr) : MetaM Expr := mkAppM ``Verity.pure #[x] +private def seq (m t : Expr) (k : Expr → MetaM Expr) : MetaM Expr := + withLocalDeclD `value t fun x => do + let next ← k x + mkAppM ``Verity.bind #[m, ← mkLambdaFVars #[x] next] + +private def register (name : Name) (value : Expr) : MetaM Unit := do + if (← getEnv).contains name then throwError "declaration collision: {name}" + let value ← instantiateMVars value + let type ← instantiateMVars (← inferType value) + if value.hasMVar || value.hasFVar || type.hasMVar || type.hasFVar then + throwError "unclosed imported declaration {name}" + addDecl (.defnDecl { name, levelParams := [], type, value, hints := .regular 0, safety := .safe }) + (forceExpose := true) + compileDecls #[name] (logErrors := false) + +private abbrev Locals := List (Nat × String × Expr) +private abbrev Slots := List (Nat × Expr) + +private def lookupLocal (locals : Locals) (id : Nat) : Option (String × Expr) := + (locals.find? fun x => x.1 == id).map fun x => (x.2.1, x.2.2) + +private def lookupSlot (slots : Slots) (id : Nat) : MetaM Expr := + match slots.lookup id with + | some e => pure e + | none => throwError "unresolved declaration id {id}" + +private def checked (op : String) (a b : Expr) : MetaM Expr := do + let fn ← match op with + | "+" | "+=" => pure ``Verity.Stdlib.Math.safeAdd + | "-" | "-=" => pure ``Verity.Stdlib.Math.safeSub + | _ => throwError "unsupported arithmetic {op}" + mkAppM ``Verity.Stdlib.Math.requireSomeUint #[← mkAppM fn #[a, b], mkStrLit "Panic(0x11)"] + +private def requireType (frontend : Frontend) (j : Json) (expected : String) : MetaM Unit := do + let actual ← typeString j + let identifier ← str (← field (← field j "typeDescriptions") "typeIdentifier") + let expectedIdentifier := match expected with + | "uint256" => "t_uint256" + | "address" => "t_address" + | "mapping(address => uint256)" => "t_mapping$_t_address_$_t_uint256_$" + | "msg" => "t_magic_message" + | "bool" => "t_bool" + | _ => "" + needAt frontend.source j (actual == expected && identifier == expectedIdentifier) ("expected " ++ expected) + +private partial def translateExpr (frontend : Frontend) (slots : Slots) (locals : Locals) (j : Json) + (k : Expr → MetaM Expr) : MetaM Expr := do + match ← nodeKind j with + | "Identifier" => + let rid ← int (← field j "referencedDeclaration") + if rid < 0 then failAt frontend.source j "unresolved builtin identifier" + let id := rid.toNat + if let some (typ, value) := lookupLocal locals id then + requireType frontend j typ + k value + else + let some info := frontend.fields.find? (·.id == id) + | failAt frontend.source j "unresolved declaration reference" + needAt frontend.source j (!info.mapping) "mapping requires index access" + requireType frontend j "uint256" + seq (← mkAppM ``Verity.getStorage #[← lookupSlot slots id]) uint k + | "MemberAccess" => + let base ← field j "expression" + needAt frontend.source j ((← str (← field j "memberName")) == "sender" && + (← nodeKind base) == "Identifier" && (← str (← field base "name")) == "msg" && + (← int (← field base "referencedDeclaration")) == -15 && + (field? j "referencedDeclaration").all Json.isNull) "only builtin msg.sender supported" + requireType frontend base "msg" + requireType frontend j "address" + seq (mkConst ``Verity.msgSender) address k + | "IndexAccess" => + let base ← field j "baseExpression" + needAt frontend.source j ((← nodeKind base) == "Identifier") "unsupported index base" + let rid ← int (← field base "referencedDeclaration") + let some info := if rid < 0 then none else frontend.fields.find? (·.id == rid.toNat) + | failAt frontend.source j "unsupported index base" + needAt frontend.source j info.mapping "unsupported index base" + requireType frontend base "mapping(address => uint256)" + let index ← field j "indexExpression" + requireType frontend index "address" + requireType frontend j "uint256" + translateExpr frontend slots locals index fun key => do + let slot ← lookupSlot slots info.id + seq (← mkAppM ``Verity.getMapping #[slot, key]) uint k + | "Literal" => + let value ← str (← field j "value") + let some n := value.toNat? | failAt frontend.source j "unsupported literal" + needAt frontend.source j ((← str (← field j "kind")) == "number" && + (field? j "subdenomination").all Json.isNull && n < 2^256) "unsupported literal" + needAt frontend.source j ((← typeString j).startsWith "int_const ") "unsupported literal type" + k (← mkAppM ``Verity.Core.Uint256.ofNat #[mkNatLit n]) + | "BinaryOperation" => + let op ← str (← field j "operator") + needAt frontend.source j (op == "+" || op == "-") "unsupported binary operation/types" + requireType frontend j "uint256" + let left ← field j "leftExpression" + let right ← field j "rightExpression" + requireType frontend left "uint256"; requireType frontend right "uint256" + translateExpr frontend slots locals left fun a => do + translateExpr frontend slots locals right fun b => do + seq (← checked op a b) uint k + | _ => failAt frontend.source j "unsupported expression" + +private def translateLValue (frontend : Frontend) (slots : Slots) (locals : Locals) (j : Json) + (k : Expr → Option Expr → MetaM Expr) : MetaM Expr := do + match ← nodeKind j with + | "Identifier" => + let rid ← int (← field j "referencedDeclaration") + let some info := if rid < 0 then none else frontend.fields.find? (·.id == rid.toNat) + | failAt frontend.source j "unsupported storage lvalue" + needAt frontend.source j (!info.mapping) "mapping requires index access" + requireType frontend j "uint256" + k (← lookupSlot slots info.id) none + | "IndexAccess" => + let base ← field j "baseExpression" + needAt frontend.source j ((← nodeKind base) == "Identifier") "unsupported index base" + let rid ← int (← field base "referencedDeclaration") + let some info := if rid < 0 then none else frontend.fields.find? (·.id == rid.toNat) + | failAt frontend.source j "unsupported index base" + needAt frontend.source j info.mapping "unsupported index base" + requireType frontend base "mapping(address => uint256)" + let index ← field j "indexExpression" + requireType frontend index "address" + requireType frontend j "uint256" + translateExpr frontend slots locals index fun key => do + k (← lookupSlot slots info.id) (some key) + | _ => failAt frontend.source j "only storage assignment supported" + +private partial def translateStmts (frontend : Frontend) (slots : Slots) (locals : Locals) + (returns : String) (nodes : List Json) : MetaM Expr := do + match nodes with + | [] => + if returns == "unit" then ret (mkConst ``Unit.unit) + else throwError "missing terminal return" + | node :: rest => + match ← nodeKind node with + | "ExpressionStatement" => + let assignment ← field node "expression" + needAt frontend.source node ((← nodeKind assignment) == "Assignment") "unsupported expression statement" + requireType frontend assignment "uint256" + let op ← str (← field assignment "operator") + needAt frontend.source assignment (op == "=" || op == "+=" || op == "-=") "unsupported assignment" + translateLValue frontend slots locals (← field assignment "leftHandSide") fun slot key => do + let write (value : Expr) : MetaM Expr := do + let action ← match key with + | none => mkAppM ``Verity.setStorage #[slot, value] + | some index => mkAppM ``Verity.setMapping #[slot, index, value] + seq action unit fun _ => translateStmts frontend slots locals returns rest + let rhsNode ← field assignment "rightHandSide" + if op == "=" then translateExpr frontend slots locals rhsNode write + else + let read ← match key with + | none => mkAppM ``Verity.getStorage #[slot] + | some index => mkAppM ``Verity.getMapping #[slot, index] + seq read uint fun old => do + translateExpr frontend slots locals rhsNode fun rhs => do + seq (← checked op old rhs) uint write + | "VariableDeclarationStatement" => + let declarations ← arr (← field node "declarations") + needAt frontend.source node (declarations.size == 1) "unsupported locals" + let declaration := declarations[0]! + needAt frontend.source declaration (!(← bool (← field declaration "stateVariable")) && + !(← bool (← field declaration "constant")) && + (← str (← field declaration "mutability")) == "mutable" && + (← str (← field declaration "storageLocation")) == "default" && + (← str (← field declaration "visibility")) == "internal") "unsupported local declaration" + requireType frontend declaration "uint256" + let id ← nodeId declaration + let _ ← identifier frontend.source declaration + let initialValue ← field node "initialValue" + translateExpr frontend slots locals initialValue fun value => + translateStmts frontend slots ((id, "uint256", value) :: locals) returns rest + | "IfStatement" => + let falseBody := field? node "falseBody" + let trueBody ← field node "trueBody" + let statements ← arr (← field trueBody "statements") + needAt frontend.source node (falseBody.all Json.isNull && statements.size == 1 && + (← nodeKind statements[0]!) == "RevertStatement") "only if/revert guard supported" + let call ← field statements[0]! "errorCall" + let callee ← field call "expression" + let rid ← int (← field callee "referencedDeclaration") + let some errorName := if rid < 0 then none else frontend.errors.lookup rid.toNat + | failAt frontend.source node "unsupported revert" + needAt frontend.source node ((← nodeKind callee) == "Identifier" && + (← arr (← field call "arguments")).isEmpty) "unsupported revert" + let condition ← field node "condition" + needAt frontend.source condition ((← nodeKind condition) == "BinaryOperation" && + (← str (← field condition "operator")) == "<") "only uint256 comparison guard supported" + requireType frontend condition "bool" + let commonType ← field condition "commonType" + needAt frontend.source condition ((← str (← field commonType "typeString")) == "uint256" && + (← str (← field commonType "typeIdentifier")) == "t_uint256") "unsupported comparison type" + let left ← field condition "leftExpression" + let right ← field condition "rightExpression" + requireType frontend left "uint256"; requireType frontend right "uint256" + translateExpr frontend slots locals left fun a => + translateExpr frontend slots locals right fun b => do + let av ← mkAppM ``Verity.Core.Uint256.val #[a] + let bv ← mkAppM ``Verity.Core.Uint256.val #[b] + let allowed ← mkAppM ``Nat.ble #[bv, av] + let guard ← mkAppM ``Verity.require #[allowed, mkStrLit (errorName ++ "()")] + seq guard unit fun _ => translateStmts frontend slots locals returns rest + | "Return" => + needAt frontend.source node (rest.isEmpty && returns == "uint256") "only terminal scalar return" + translateExpr frontend slots locals (← field node "expression") ret + | _ => failAt frontend.source node "unsupported statement" + +private def nonpayable (m : Expr) : MetaM Expr := + seq (mkConst ``Verity.msgValue) uint fun value => do + let n ← mkAppM ``Verity.Core.Uint256.val #[value] + let zero ← mkAppM ``Nat.beq #[n, mkNatLit 0] + let guard ← mkAppM ``Verity.require #[zero, mkStrLit "Nonpayable"] + seq guard unit fun _ => pure m + +private def validateValueDecl (frontend : Frontend) (p : Json) : MetaM Unit := do + needAt frontend.source p (!(← bool (← field p "stateVariable")) && + !(← bool (← field p "constant")) && + (← str (← field p "mutability")) == "mutable" && + (← str (← field p "storageLocation")) == "default" && + (← str (← field p "visibility")) == "internal" && + (field? p "value").all Json.isNull) "unsupported parameter declaration" + +private partial def translateParams (frontend : Frontend) (params : List Json) (locals : Locals) + (k : Locals → MetaM Expr) : MetaM Expr := do + match params with + | [] => k locals + | p :: ps => + validateValueDecl frontend p + let typ ← typeString p + needAt frontend.source p (typ == "uint256" || typ == "address") "unsupported value type" + let name ← identifier frontend.source p + let id ← nodeId p + withLocalDeclD (Name.mkSimple name) (← valueType typ) fun x => do + mkLambdaFVars #[x] (← translateParams frontend ps ((id, typ, x) :: locals) k) + +private def importFrontend (ns : Name) (frontend : Frontend) : MetaM Unit := do + if debug.skipKernelTC.get (← getOptions) then throwError "kernel checking must be enabled" + let mut names := #[ns ++ `sourceDigest] + for f in frontend.fields do + names := names.push (ns ++ Name.mkSimple f.name) + if let some getter := f.getter then names := names.push (ns ++ Name.mkSimple getter) + for fn in frontend.functions do + let name ← identifier frontend.source fn + names := names.push (ns ++ Name.mkSimple name) + for i in [:names.size] do + if (← getEnv).contains names[i]! || (names.extract 0 i).contains names[i]! then + throwError "declaration collision: {names[i]!}" + let mut slots : Slots := [] + for f in frontend.fields do + let ty ← if f.mapping then mkArrow address uint else pure uint + let slot ← mkAppOptM ``Verity.StorageSlot.mk #[some ty, some (mkNatLit f.slot)] + let name := ns ++ Name.mkSimple f.name + register name slot + slots := (f.id, mkConst name) :: slots + for f in frontend.fields do + if let some getter := f.getter then + let slot ← lookupSlot slots f.id + let value ← if f.mapping then + withLocalDeclD `account address fun account => do + let getter ← mkAppM ``Verity.getMapping #[slot, account] + mkLambdaFVars #[account] (← nonpayable getter) + else nonpayable (← mkAppM ``Verity.getStorage #[slot]) + register (ns ++ Name.mkSimple getter) value + for fn in frontend.functions do + let name ← identifier frontend.source fn + needAt frontend.source fn ((← str (← field fn "kind")) == "function" && + (← bool (← field fn "implemented")) && (← arr (← field fn "modifiers")).isEmpty && + !(← bool (← field fn "virtual")) && (field? fn "overrides").all Json.isNull && + ["external", "public"].contains (← str (← field fn "visibility")) && + ["nonpayable", "view"].contains (← str (← field fn "stateMutability"))) "unsupported function surface" + let ps ← arr (← field (← field fn "parameters") "parameters") + let rs ← arr (← field (← field fn "returnParameters") "parameters") + needAt frontend.source fn (ps.size <= 1 && rs.size <= 1) "unsupported signature" + for r in rs do + validateValueDecl frontend r + needAt frontend.source r ((← str (← field r "name")).isEmpty && (← typeString r) == "uint256") + "unsupported return type" + let returns := if rs.isEmpty then "unit" else "uint256" + let value ← translateParams frontend ps.toList [] fun locals => do + let code ← translateStmts frontend slots locals returns + (← arr (← field (← field fn "body") "statements")).toList + let expected ← mkAppM ``Verity.Contract #[← valueType returns] + unless ← isDefEq (← inferType code) expected do + throwError "imported body does not match typed AST return signature" + nonpayable code + register (ns ++ Name.mkSimple name) value + register (ns ++ `sourceDigest) (mkStrLit frontend.digest) + +private def compileFrontend (root source : System.FilePath) : MetaM Frontend := do + let canonicalRoot ← IO.FS.realPath root + let canonicalSource ← IO.FS.realPath source + unless canonicalSource.toString.startsWith (canonicalRoot.toString ++ "/") do + throwError "source outside package" + let expected ← IO.FS.realPath (canonicalRoot / registeredSource) + unless canonicalSource == expected do throwError "unregistered source or source outside package" + let compiler := canonicalRoot / ".lake/solidity-import/solc" + verifyCompiler compiler + let versionOut ← IO.Process.output { cmd := compiler.toString, args := #["--version"] } + unless versionOut.exitCode == 0 && versionOut.stdout.trimAscii.toString == solcVersionOutput do + throwError "compiler version mismatch" + verifyCompiler compiler + let sourceBytes ← IO.FS.readBinFile canonicalSource + let sourceText ← match String.fromUTF8? sourceBytes with + | some text => pure text + | none => throwError "Solidity source is not UTF-8" + let settings := Json.mkObj [ + ("optimizer", Json.mkObj [("enabled", false)]), + ("viaIR", false), ("evmVersion", "cancun"), ("remappings", Json.arr #[]), + ("outputSelection", Json.mkObj [("*", Json.mkObj [ + ("", Json.arr #["ast"]), ("*", Json.arr #["storageLayout"])])])] + let input := Json.mkObj [("language", "Solidity"), + ("sources", Json.mkObj [(registeredSource, Json.mkObj [("content", sourceText)])]), + ("settings", settings)] + let output ← IO.Process.output + { cmd := compiler.toString, args := #["--standard-json", "--no-import-callback"] } + (some input.compress) + unless output.exitCode == 0 do throwError "solc failed: {output.stderr}" + verifyCompiler compiler + let importerText ← IO.FS.readFile + (canonicalRoot / "Contracts/VaultFromSolidity/Importer/Importer.lean") + parseCompilerOutput canonicalSource registeredSource sourceBytes output.stdout versionOut.stdout importerText + +syntax (name := solidityContract) "solidity_contract " ident " from " str : command + +@[command_elab solidityContract] def elabSolidityContract : CommandElab := fun stx => do + let saved ← getEnv + try + let authored ← IO.FS.realPath (← getFileName) + let source := authored.parent.getD "." / stx[3].isStrLit?.get! + let mut root := authored.parent.getD "." + while !(← (root / "lakefile.lean").pathExists) do + let some parent := root.parent | throwError "package root not found" + if parent == root then throwError "package root not found" + root := parent + let frontend ← liftTermElabM <| compileFrontend root source + let ns := (← getCurrNamespace) ++ stx[1].getId + liftTermElabM <| withOptions (Elab.async.set · false) (importFrontend ns frontend) + catch e => + setEnv saved + throw e + +end SolidityImporter diff --git a/Contracts/VaultFromSolidity/Importer/SolidityImporter.lean b/Contracts/VaultFromSolidity/Importer/SolidityImporter.lean deleted file mode 100644 index 5f6eac7ab4..0000000000 --- a/Contracts/VaultFromSolidity/Importer/SolidityImporter.lean +++ /dev/null @@ -1,220 +0,0 @@ -import Lean -import Verity.Stdlib.Math - -/-! Proof-only, closed typed-AST importer for the Vault-from-Solidity example. -/ -open Lean Meta Elab Command - -namespace SolidityImporter - -private def field (j : Json) (key : String) : MetaM Json := - match j.getObjVal? key with - | .ok v => pure v - | .error e => throwError "{e}" -private def str (j : Json) : MetaM String := - match j.getStr? with - | .ok v => pure v - | .error e => throwError "{e}" -private def num (j : Json) : MetaM Nat := - match j.getNat? with - | .ok v => pure v - | .error e => throwError "{e}" -private def arr (j : Json) : MetaM (Array Json) := - match j.getArr? with - | .ok v => pure v - | .error e => throwError "{e}" -private def item (j : Json) (i : Nat) : MetaM Json := do - let a ← arr j - if h : i < a.size then pure a[i] else throwError "missing AST operand" -private def tag (j : Json) : MetaM String := item j 0 >>= str -private def uint := mkConst ``Verity.Core.Uint256 -private def address := mkConst ``Verity.Core.Address -private def unit := mkConst ``Unit -private def valueType (s : String) : MetaM Expr := - match s with - | "uint256" => pure uint - | "address" => pure address - | "unit" => pure unit - | _ => throwError "unsupported type {s}" -private def ret (x : Expr) : MetaM Expr := mkAppM ``Verity.pure #[x] -private def seq (m t : Expr) (k : Expr → MetaM Expr) : MetaM Expr := - withLocalDeclD `value t fun x => do - let body ← k x - mkAppM ``Verity.bind #[m, ← mkLambdaFVars #[x] body] - -private def register (name : Name) (value : Expr) : MetaM Unit := do - if (← getEnv).contains name then throwError "declaration collision: {name}" - let value ← instantiateMVars value - let type ← instantiateMVars (← inferType value) - if value.hasMVar || value.hasFVar || type.hasMVar || type.hasFVar then - throwError "unclosed imported declaration {name}" - addDecl (.defnDecl { - name := name - levelParams := [] - type := type - value := value - hints := .regular 0 - safety := .safe }) (forceExpose := true) - compileDecls #[name] (logErrors := false) - -private abbrev Locals := List (Nat × Expr) -private abbrev Slots := List (Nat × Expr) -private def lookup (xs : List (Nat × Expr)) (id : Nat) : MetaM Expr := - match xs.lookup id with - | some e => pure e - | none => throwError "unresolved declaration id {id}" - -private def checked (op : String) (a b : Expr) : MetaM Expr := do - let fn ← match op with - | "+" | "+=" => pure ``Verity.Stdlib.Math.safeAdd - | "-" | "-=" => pure ``Verity.Stdlib.Math.safeSub - | _ => throwError "unsupported arithmetic {op}" - mkAppM ``Verity.Stdlib.Math.requireSomeUint #[← mkAppM fn #[a, b], mkStrLit "Panic(0x11)"] - -private partial def eval (slots : Slots) (locals : Locals) (j : Json) - (k : Expr → MetaM Expr) : MetaM Expr := do - match ← tag j with - | "local" => k (← lookup locals (← num (← item j 1))) - | "number" => k (← mkAppM ``Verity.Core.Uint256.ofNat #[mkNatLit (← num (← item j 1))]) - | "sender" => seq (mkConst ``Verity.msgSender) address k - | "read" => - seq (← mkAppM ``Verity.getStorage #[← lookup slots (← num (← item j 1))]) uint k - | "map" => - let slot ← lookup slots (← num (← item j 1)) - eval slots locals (← item j 2) fun key => do - seq (← mkAppM ``Verity.getMapping #[slot, key]) uint k - | "+" | "-" => - let op ← tag j - eval slots locals (← item j 1) fun a => do - eval slots locals (← item j 2) fun b => do - seq (← checked op a b) uint k - | t => throwError "unsupported expression tag {t}" - -private partial def body (slots : Slots) (locals : Locals) (nodes : List Json) : MetaM Expr := do - match nodes with - | [] => ret (mkConst ``Unit.unit) - | j :: rest => - match ← tag j with - | "return" => - unless rest.isEmpty do throwError "nonterminal return" - eval slots locals (← item j 1) ret - | "let" => - let id ← num (← item j 1) - eval slots locals (← item j 2) fun v => body slots ((id, v) :: locals) rest - | "guard" => - let cond ← item j 1 - unless (← tag cond) == "<" do throwError "unsupported guard" - eval slots locals (← item cond 1) fun a => do - eval slots locals (← item cond 2) fun b => do - let av ← mkAppM ``Verity.Core.Uint256.val #[a] - let bv ← mkAppM ``Verity.Core.Uint256.val #[b] - let allowed ← mkAppM ``Nat.ble #[bv, av] - let guard ← mkAppM ``Verity.require #[allowed, mkStrLit (← str (← item j 2))] - seq guard unit fun _ => body slots locals rest - | "write" => - let lhs ← item j 1 - let op ← str (← item j 2) - let rhs ← item j 3 - let slot ← lookup slots (← num (← item lhs 1)) - let finish (key : Option Expr) : MetaM Expr := do - let write (v : Expr) : MetaM Expr := do - let m ← match key with - | none => mkAppM ``Verity.setStorage #[slot, v] - | some key => mkAppM ``Verity.setMapping #[slot, key, v] - seq m unit fun _ => body slots locals rest - if op == "=" then eval slots locals rhs write - else - let read ← match key with - | none => mkAppM ``Verity.getStorage #[slot] - | some key => mkAppM ``Verity.getMapping #[slot, key] - seq read uint fun old => eval slots locals rhs fun rhs => do - seq (← checked op old rhs) uint write - match ← tag lhs with - | "read" => finish none - | "map" => eval slots locals (← item lhs 2) fun key => finish (some key) - | _ => throwError "unsupported lvalue" - | t => throwError "unsupported statement tag {t}" - -private def nonpayable (m : Expr) : MetaM Expr := - seq (mkConst ``Verity.msgValue) uint fun value => do - let n ← mkAppM ``Verity.Core.Uint256.val #[value] - let zero ← mkAppM ``Nat.beq #[n, mkNatLit 0] - let guard ← mkAppM ``Verity.require #[zero, mkStrLit "Nonpayable"] - seq guard unit fun _ => pure m - -private partial def params (ps : List Json) (locals : Locals) - (k : Locals → MetaM Expr) : MetaM Expr := do - match ps with - | [] => k locals - | p :: ps => - let id ← num (← field p "id") - let name ← str (← field p "name") - withLocalDeclD (Name.mkSimple name) (← valueType (← str (← field p "type"))) fun x => do - mkLambdaFVars #[x] (← params ps ((id, x) :: locals) k) - -private def importModel (ns : Name) (model : Json) : MetaM Unit := do - if debug.skipKernelTC.get (← getOptions) then throwError "kernel checking must be enabled" - let fs ← arr (← field model "fields") - let functions ← arr (← field model "functions") - -- Preflight every name before registering any declaration. - let mut names := #[ns ++ `sourceDigest] - for f in fs do - names := names.push (ns ++ Name.mkSimple (← str (← field f "name"))) - if let .str s ← field f "getter" then names := names.push (ns ++ Name.mkSimple s) - for f in functions do names := names.push (ns ++ Name.mkSimple (← str (← field f "name"))) - for i in [:names.size] do - if (← getEnv).contains names[i]! || (names.extract 0 i).contains names[i]! then - throwError "declaration collision: {names[i]!}" - let mut slots := [] - for f in fs do - let mapping := (← field f "mapping") == Json.bool true - let ty ← if mapping then mkArrow address uint else pure uint - let slot ← mkAppOptM ``Verity.StorageSlot.mk #[some ty, some (mkNatLit (← num (← field f "slot")))] - let name := ns ++ Name.mkSimple (← str (← field f "name")) - register name slot - slots := (← num (← field f "id"), mkConst name) :: slots - for f in fs do - if let .str getter ← field f "getter" then - let slot ← lookup slots (← num (← field f "id")) - let value ← if (← field f "mapping") == Json.bool true then - withLocalDeclD `account address fun x => do - mkLambdaFVars #[x] (← nonpayable (← mkAppM ``Verity.getMapping #[slot, x])) - else nonpayable (← mkAppM ``Verity.getStorage #[slot]) - register (ns ++ Name.mkSimple getter) value - for f in functions do - let value ← params (← arr (← field f "params")).toList [] fun locals => do - let code ← body slots locals (← arr (← field f "body")).toList - let expected ← mkAppM ``Verity.Contract #[← valueType (← str (← field f "returns"))] - unless ← isDefEq (← inferType code) expected do - throwError "imported body does not match typed AST return signature" - nonpayable code - register (ns ++ Name.mkSimple (← str (← field f "name"))) value - register (ns ++ `sourceDigest) (mkStrLit (← str (← field model "digest"))) - -syntax (name := solidityContract) "solidity_contract " ident " from " str : command - -@[command_elab solidityContract] def elabSolidityContract : CommandElab := fun stx => do - let saved ← getEnv - try - let file ← getFileName - let authored ← IO.FS.realPath file - let source := authored.parent.getD "." / stx[3].isStrLit?.get! - let mut root := authored.parent.getD "." - while !(← (root / "lakefile.lean").pathExists) do - let some p := root.parent | throwError "package root not found" - if p == root then throwError "package root not found" - root := p - let frontend := root / "Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py" - let output ← IO.Process.output {cmd := "python3", args := #[frontend.toString, source.toString]} - unless output.exitCode == 0 do throwError "Solidity import failed:\n{output.stderr}" - let model ← match Json.parse output.stdout with - | .ok j => pure j - | .error e => throwError "invalid frontend JSON: {e}" - let ns := (← getCurrNamespace) ++ stx[1].getId - -- A checking error must be raised inside this transaction, not in a later - -- async task after partial declarations have escaped the rollback handler. - liftTermElabM <| withOptions (Elab.async.set · false) (importModel ns model) - catch e => - setEnv saved - throw e - -end SolidityImporter diff --git a/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py deleted file mode 100644 index f7beaf8ebf..0000000000 --- a/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -"""Closed Vault-feature typed-AST frontend. Emits JSON, never Lean source.""" -import hashlib -import json -import pathlib -import platform -import re -import subprocess -import sys - -ROOT = pathlib.Path(__file__).resolve().parents[4] -SOURCE = 'Contracts/VaultFromSolidity/Vault.sol' -PINS = { - 'Linux': '1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468', - 'Darwin': '8324280591ce398d7e2722846bc10ecf1779b13a328ef97b687c92cd9c70801a', -} -SETTINGS = dict(optimizer={'enabled': False}, viaIR=False, evmVersion='cancun', - remappings=[], outputSelection={'*': {'': ['ast'], '*': ['storageLayout']}}) - -def digest(b): - return hashlib.sha256(b).hexdigest() - -# Closed schema for pinned solc's accepted AST. Metadata is explicitly typed, -# never a catch-all escape hatch for unknown executable children. -NODE_FIELDS = { - 'SourceUnit': 'absolutePath exportedSymbols license nodes', - 'PragmaDirective': 'literals', - 'ContractDefinition': 'abstract baseContracts canonicalName contractDependencies contractKind documentation fullyImplemented linearizedBaseContracts name nameLocation nodes scope usedErrors usedEvents storageLayout', - 'StructuredDocumentation': 'text', - 'VariableDeclaration': 'constant functionSelector mutability name nameLocation scope stateVariable storageLocation typeDescriptions typeName visibility value documentation overrides indexed', - 'ElementaryTypeName': 'name stateMutability typeDescriptions', - 'Mapping': 'keyName keyNameLocation keyType typeDescriptions valueName valueNameLocation valueType', - 'ErrorDefinition': 'errorSelector name nameLocation parameters documentation', - 'FunctionDefinition': 'body functionSelector implemented kind modifiers name nameLocation parameters returnParameters scope stateMutability virtual visibility documentation overrides baseFunctions', - 'ParameterList': 'parameters', - 'Block': 'statements documentation', - 'ExpressionStatement': 'expression', - 'Assignment': 'leftHandSide operator rightHandSide', - 'BinaryOperation': 'commonType leftExpression operator rightExpression function', - 'Identifier': 'argumentTypes name overloadedDeclarations referencedDeclaration', - 'MemberAccess': 'expression memberLocation memberName referencedDeclaration', - 'IndexAccess': 'baseExpression indexExpression', - 'Literal': 'hexValue kind subdenomination value', - 'FunctionCall': 'arguments expression kind nameLocations names tryCall', - 'VariableDeclarationStatement': 'assignments declarations initialValue', - 'IfStatement': 'condition trueBody falseBody', - 'RevertStatement': 'errorCall', - 'Return': 'expression functionReturnParameters', -} -EXPRESSION_NODES = {'Assignment', 'BinaryOperation', 'Identifier', 'MemberAccess', - 'IndexAccess', 'Literal', 'FunctionCall'} -CHILDREN = {'nodes', 'baseContracts', 'parameters', 'returnParameters', 'body', - 'statements', 'typeName', 'keyType', 'valueType', 'value', 'modifiers', - 'overrides', 'storageLayout', 'leftHandSide', 'rightHandSide', - 'leftExpression', 'rightExpression', 'expression', 'baseExpression', - 'indexExpression', 'arguments', 'declarations', 'initialValue', - 'condition', 'trueBody', 'falseBody', 'errorCall'} -STRING_FIELDS = set('absolutePath license canonicalName contractKind name nameLocation text functionSelector mutability storageLocation visibility stateMutability keyName keyNameLocation valueName valueNameLocation errorSelector kind operator memberLocation memberName hexValue subdenomination'.split()) -BOOL_FIELDS = set('abstract fullyImplemented constant stateVariable indexed implemented virtual isConstant isLValue isPure lValueRequested tryCall'.split()) -INT_FIELDS = {'scope', 'referencedDeclaration', 'functionReturnParameters', 'function'} -INT_LIST_FIELDS = {'contractDependencies', 'linearizedBaseContracts', 'usedErrors', 'usedEvents', 'baseFunctions', 'overloadedDeclarations', 'assignments'} -STRING_LIST_FIELDS = {'literals', 'names', 'nameLocations'} - - -def validate_ast(ast, need): - def types(value, owner): - need(isinstance(value, dict) and set(value) <= {'typeIdentifier', 'typeString'} - and all(isinstance(v, str) for v in value.values()), owner, 'invalid type metadata') - def visit(n): - need(isinstance(n, dict) and n.get('nodeType') in NODE_FIELDS, n if isinstance(n, dict) else ast, 'unsupported AST node') - k = n['nodeType'] - if k == 'ContractDefinition': - need(n.get('storageLayout') is None, n, 'contract layout at specifier unsupported') - allowed = set(NODE_FIELDS[k].split()) | {'id', 'src', 'nodeType'} - if k in EXPRESSION_NODES: - allowed |= {'isConstant', 'isLValue', 'isPure', 'lValueRequested', 'typeDescriptions'} - need(not (set(n) - allowed), n, 'unexpected AST fields: ' + ', '.join(sorted(set(n) - allowed))) - need(type(n.get('id')) is int and isinstance(n.get('src'), str), n, 'missing AST identity/span') - for key, v in n.items(): - if key in {'id', 'src', 'nodeType'}: - continue - if key == 'documentation': - if isinstance(v, dict): - need(v.get('nodeType') == 'StructuredDocumentation', n, 'invalid documentation') - visit(v) - else: - need(v is None or isinstance(v, str), n, 'invalid documentation') - elif key in {'typeDescriptions', 'commonType'}: - types(v, n) - elif key == 'argumentTypes': - need(isinstance(v, list), n, 'invalid argument type metadata') - for t in v: - types(t, n) - elif key == 'exportedSymbols': - need(isinstance(v, dict) and all(isinstance(ids, list) and all(type(i) is int for i in ids) for ids in v.values()), n, 'invalid symbol metadata') - elif key == 'value' and k == 'Literal': - need(isinstance(v, str), n, 'invalid literal value') - elif key in CHILDREN: - if v is not None: - if isinstance(v, list): - for child in v: - if child is not None: - visit(child) - else: - visit(v) - elif key in STRING_FIELDS: - need(isinstance(v, str) or (key == 'subdenomination' and v is None), n, 'invalid string metadata: ' + key) - elif key in BOOL_FIELDS: - need(type(v) is bool, n, 'invalid boolean metadata: ' + key) - elif key in INT_FIELDS: - need(type(v) is int, n, 'invalid declaration metadata: ' + key) - elif key in INT_LIST_FIELDS: - need(isinstance(v, list) and all(type(i) is int or (key == 'assignments' and i is None) for i in v), n, 'invalid declaration list: ' + key) - elif key in STRING_LIST_FIELDS: - need(isinstance(v, list) and all(isinstance(i, str) for i in v), n, 'invalid string list: ' + key) - else: - need(False, n, 'unclassified AST field: ' + key) - # Structural positions are closed as well as node field names. In - # particular a known node kind in the wrong position is not metadata. - lists = {'nodes', 'baseContracts', 'statements', 'modifiers', 'arguments', 'declarations'} - if k == 'ParameterList': - lists.add('parameters') - nullable = {'value', 'overrides', 'storageLayout', 'falseBody'} - required = { - 'SourceUnit': ('nodes',), 'ContractDefinition': ('nodes', 'baseContracts'), - 'FunctionDefinition': ('body', 'parameters', 'returnParameters', 'modifiers'), - 'ParameterList': ('parameters',), 'Block': ('statements',), - 'VariableDeclaration': ('typeName',), 'Mapping': ('keyType', 'valueType'), - 'ExpressionStatement': ('expression',), 'Assignment': ('leftHandSide', 'rightHandSide'), - 'BinaryOperation': ('leftExpression', 'rightExpression'), - 'MemberAccess': ('expression',), 'IndexAccess': ('baseExpression', 'indexExpression'), - 'FunctionCall': ('expression', 'arguments'), - 'VariableDeclarationStatement': ('declarations', 'initialValue'), - 'IfStatement': ('condition', 'trueBody'), 'RevertStatement': ('errorCall',), - 'Return': ('expression',), 'ErrorDefinition': ('parameters',), - } - need(all(key in n for key in required.get(k, ())), n, 'missing required AST children') - for key in CHILDREN & n.keys(): - if key == 'value' and k == 'Literal': - continue - v = n[key] - if key in lists: - need(isinstance(v, list) and all(isinstance(child, dict) for child in v), n, 'invalid AST child list: ' + key) - else: - need(isinstance(v, dict) or (key in nullable and v is None), n, 'invalid AST child: ' + key) - expected = {'typeName': {'ElementaryTypeName', 'Mapping'}, - 'keyType': {'ElementaryTypeName'}, 'valueType': {'ElementaryTypeName'}, - 'errorCall': {'FunctionCall'}, 'trueBody': {'Block'}} - for key, kinds in expected.items(): - if key in n: - need(n[key].get('nodeType') in kinds, n, 'unexpected child kind: ' + key) - if k == 'ParameterList': - need(all(p['nodeType'] == 'VariableDeclaration' for p in n['parameters']), n, 'invalid parameter declaration') - if k == 'VariableDeclarationStatement': - need(all(p['nodeType'] == 'VariableDeclaration' for p in n['declarations']), n, 'invalid local declaration') - if k == 'VariableDeclaration': - need(n.get('value') is None and n.get('overrides') is None, n, 'initializer/override unsupported') - if k == 'BinaryOperation': - need(n.get('function') is None, n, 'user-defined operator unsupported') - if k == 'FunctionCall': - need(n['kind'] == 'functionCall' and not n['tryCall'] and not n['names'], n, 'unsupported call surface') - if k == 'FunctionDefinition': - need(isinstance(n.get('body'), dict) and n['body'].get('nodeType') == 'Block', n, 'function body must be Block') - for key in ('parameters', 'returnParameters'): - if key in n and k != 'ParameterList': - need(isinstance(n[key], dict) and n[key].get('nodeType') == 'ParameterList', n, key + ' must be ParameterList') - if k == 'Block': - need(isinstance(n.get('statements'), list), n, 'Block requires statements') - need(isinstance(ast, dict) and ast.get('nodeType') == 'SourceUnit', ast, 'root must be SourceUnit') - visit(ast) - - -def main(): - system = platform.system() - if system not in PINS: - raise ValueError(f'unsupported compiler platform: {system}') - pin = PINS[system] - source = pathlib.Path(sys.argv[1]).resolve(strict=True) - if not source.is_relative_to(ROOT.resolve(strict=True)): - raise ValueError('source outside package') - if source != (ROOT / SOURCE).resolve(strict=True): - raise ValueError('unregistered source or source outside package') - raw = source.read_bytes() - binary = ROOT / '.lake/solidity-import/solc' - if digest(binary.read_bytes()) != pin: - raise ValueError('compiler checksum mismatch') - version = subprocess.check_output([str(binary), '--version']).decode() - if '0.8.33+commit.64118f21.' not in version: - raise ValueError('compiler version mismatch') - inp = dict(language='Solidity', sources={SOURCE: {'content': raw.decode()}}, settings=SETTINGS) - encoded = json.dumps(inp, sort_keys=True).encode() - key = digest(encoded + pin.encode()) - cache = binary.parent / (key + '.json') - if cache.exists(): - record = json.loads(cache.read_text()) - out = record['output'] - if record['key'] != key or record['digest'] != digest(json.dumps(out, sort_keys=True).encode()): - raise ValueError('corrupt AST cache') - else: - p = subprocess.run([str(binary), '--standard-json', '--no-import-callback'], - input=encoded, capture_output=True, check=True) - out = json.loads(p.stdout) - if any(e['severity'] == 'error' for e in out.get('errors', [])): - raise ValueError('\n'.join(e['formattedMessage'] for e in out['errors'])) - record = dict(key=key, output=out, digest=digest(json.dumps(out, sort_keys=True).encode())) - tmp = cache.with_suffix('.tmp') - tmp.write_text(json.dumps(record, sort_keys=True)) - tmp.replace(cache) - if set(out['sources']) != {SOURCE}: - raise ValueError('unexpected compiler sources') - ast = out['sources'][SOURCE]['ast'] - def fail(n, why): - start, size, sid = map(int, n['src'].split(':')) - if sid != out['sources'][SOURCE]['id']: - raise ValueError('unexpected source id') - prefix = raw[:start] - line = prefix.count(b'\n') + 1 - column = len(prefix.rsplit(b'\n', 1)[-1]) + 1 - excerpt = raw[start:start + min(size, 100)].decode(errors='replace') - raise ValueError(f'{SOURCE}:{line}:{column}: {n["nodeType"]}: {why}\n{excerpt}') - def need(ok, n, why): - if not ok: - fail(n, why) - def ident(n): - name = n['name'] - need(re.fullmatch(r'[A-Za-z][A-Za-z0-9_]*', name) and name != 'sourceDigest', n, 'unsupported/reserved name') - return name - validate_ast(ast, need) - contracts = [] - for n in ast['nodes']: - if n['nodeType'] == 'PragmaDirective': - need(n['literals'][0] == 'solidity', n, 'unsupported pragma') - elif n['nodeType'] == 'ContractDefinition': - contracts.append(n) - else: - fail(n, 'unsupported source declaration') - need(len(contracts) == 1, ast, 'exactly one concrete contract required') - c = contracts[0] - need(c['contractKind'] == 'contract' and not c['abstract'] and not c['baseContracts'], c, 'inheritance/abstract contract unsupported') - layout = out['contracts'][SOURCE][c['name']]['storageLayout'] - entries = {x['astId']: x for x in layout['storage']} - fields, funcs, errors = {}, [], {} - for n in c['nodes']: - kind = n['nodeType'] - if kind == 'VariableDeclaration': - name = ident(n) - need(n['stateVariable'] and not n['constant'] and n['mutability'] == 'mutable' and n.get('value') is None and n['storageLocation'] == 'default', n, 'initializer/constant/transient field unsupported') - typ = n['typeDescriptions']['typeString'] - need(typ in ('uint256', 'mapping(address => uint256)'), n, 'unsupported storage type') - e = entries.get(n['id']) - need(e is not None and e['offset'] == 0, n, 'missing/packed layout') - t = layout['types'][e['type']] - need(t['numberOfBytes'] == '32', n, 'nonword layout') - if typ == 'uint256': - need(t['encoding'] == 'inplace' and t['label'] == typ, n, 'bad scalar layout') - else: - need(t['encoding'] == 'mapping' and layout['types'][t['key']]['label'] == 'address' and layout['types'][t['value']]['label'] == 'uint256', n, 'bad mapping layout') - fields[n['id']] = dict(id=n['id'], name=name + 'Slot', getter=name if n['visibility'] == 'public' else None, slot=int(e['slot']), mapping=typ.startswith('mapping')) - elif kind == 'ErrorDefinition': - need(not n['parameters']['parameters'], n, 'only zero-argument custom errors') - errors[n['id']] = ident(n) - elif kind == 'FunctionDefinition': - funcs.append(n) - else: - fail(n, 'unsupported contract declaration') - need(set(entries) == set(fields), c, 'unaccounted layout field') - def ty(n): - t = n['typeDescriptions']['typeString'] - need(t in ('uint256', 'address'), n, 'unsupported value type') - return t - def expr(n, scope): - k = n['nodeType'] - if k == 'Identifier': - rid = n['referencedDeclaration'] - if rid in scope: - need(ty(n) == scope[rid], n, 'reference type mismatch') - return ['local', rid] - need(rid in fields and not fields[rid]['mapping'], n, 'unresolved/non-scalar reference') - return ['read', rid] - if k == 'MemberAccess': - b = n['expression'] - need(n['memberName'] == 'sender' and b['nodeType'] == 'Identifier' and b['name'] == 'msg' and b['referencedDeclaration'] < 0 and ty(n) == 'address', n, 'only builtin msg.sender supported') - return ['sender'] - if k == 'IndexAccess': - b = n['baseExpression'] - need(b['nodeType'] == 'Identifier' and b['referencedDeclaration'] in fields and fields[b['referencedDeclaration']]['mapping'], n, 'unsupported index base') - need(ty(n['indexExpression']) == 'address' and ty(n) == 'uint256', n, 'bad mapping index/value type') - return ['map', b['referencedDeclaration'], expr(n['indexExpression'], scope)] - if k == 'Literal': - need(n['kind'] == 'number' and not n.get('subdenomination') and re.fullmatch('[0-9]+', n['value']) and int(n['value']) < 2**256, n, 'unsupported literal') - return ['number', int(n['value'])] - if k == 'BinaryOperation': - need(n['operator'] in ('+', '-', '<') and ty(n['leftExpression']) == 'uint256' and ty(n['rightExpression']) == 'uint256', n, 'unsupported binary operation/types') - return [n['operator'], expr(n['leftExpression'], scope), expr(n['rightExpression'], scope)] - fail(n, 'unsupported expression') - def statements(nodes, scope, returns): - result = [] - for i, n in enumerate(nodes): - k = n['nodeType'] - if k == 'ExpressionStatement': - a = n['expression'] - need(a['nodeType'] == 'Assignment' and a['operator'] in ('=', '+=', '-='), n, 'unsupported expression statement') - lhs = expr(a['leftHandSide'], scope) - need(lhs[0] in ('read', 'map'), a, 'only storage assignment supported') - rhs = expr(a['rightHandSide'], scope) - result.append(['write', lhs, a['operator'], rhs]) - elif k == 'VariableDeclarationStatement': - ds = n['declarations'] - need(len(ds) == 1 and ds[0] is not None and n['initialValue'] is not None, n, 'unsupported locals') - d = ds[0] - need(ty(d) == 'uint256', d, 'only uint256 locals') - value = expr(n['initialValue'], scope) - scope = dict(scope, **{}) - scope[d['id']] = ty(d) - result.append(['let', d['id'], value]) - elif k == 'IfStatement': - need(n.get('falseBody') is None and n['trueBody']['nodeType'] == 'Block', n, 'only if/revert guard supported') - body = n['trueBody']['statements'] - need(len(body) == 1 and body[0]['nodeType'] == 'RevertStatement', n, 'only if/revert guard supported') - call = body[0]['errorCall'] - callee = call['expression'] - need(callee['nodeType'] == 'Identifier' and callee['referencedDeclaration'] in errors and not call['arguments'], n, 'unsupported revert') - condition = expr(n['condition'], scope) - need(condition[0] == '<', n, 'only uint256 comparison guard supported') - # Match Verity's zero-argument custom-error display convention. - result.append(['guard', condition, errors[callee['referencedDeclaration']] + '()']) - elif k == 'Return': - need(i == len(nodes)-1 and returns == 'uint256' and n['expression'] is not None, n, 'only terminal scalar return') - result.append(['return', expr(n['expression'], scope)]) - else: - fail(n, 'unsupported statement') - if returns != 'unit': - need(result and result[-1][0] == 'return', c, 'missing terminal return') - return result - output = [] - for f in funcs: - name = ident(f) - need(f['kind'] == 'function' and f['implemented'] and not f['modifiers'] and not f['virtual'] and not f.get('overrides') and f['visibility'] in ('external', 'public') and f['stateMutability'] in ('nonpayable', 'view'), f, 'unsupported function surface') - ps = f['parameters']['parameters'] - rs = f['returnParameters']['parameters'] - need(len(ps) <= 1 and len(rs) <= 1 and all(not r['name'] and ty(r) == 'uint256' for r in rs), f, 'unsupported signature') - params = [dict(id=p['id'], name=ident(p), type=ty(p)) for p in ps] - returns = 'uint256' if rs else 'unit' - output.append(dict(name=name, params=params, returns=returns, body=statements(f['body']['statements'], {p['id']: p['type'] for p in params}, returns))) - names = [f['name'] for f in fields.values()] + [f['getter'] for f in fields.values() if f['getter']] + [f['name'] for f in output] + ['sourceDigest'] - need(len(names) == len(set(names)), c, 'overload/generated name collision') - print(json.dumps(dict(fields=list(fields.values()), functions=output, - digest=digest(json.dumps(dict(input=inp, output=out, - pythonImporter=digest(pathlib.Path(__file__).read_bytes()), - leanImporter=digest((ROOT / 'Contracts/VaultFromSolidity/Importer/SolidityImporter.lean').read_bytes()), - compilerSha256=pin, compilerVersion=version), sort_keys=True).encode())))) - -if __name__ == '__main__': - try: - main() - except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as e: - print(str(e), file=sys.stderr) - sys.exit(1) diff --git a/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py index 0857d28fe3..4da220b795 100644 --- a/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py +++ b/Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py @@ -1,225 +1,172 @@ #!/usr/bin/env python3 -"""Focused Vault-from-Solidity acceptance checks; mutations use a disposable copy. +"""Acceptance checks for the Lean-only Solidity frontend. -Prerequisite: lake build VaultFromSolidity and the pinned .lake/solidity-import/solc. -Runs no bytecode compiler and writes no generated model Lean source. +Python only orchestrates disposable builds and mutations. The production import +path is `Contracts/VaultFromSolidity/Importer/Importer.lean` -> pinned solc -> checked Lean declarations. """ + import hashlib -import json import os from pathlib import Path -import platform import re import shutil import subprocess import tempfile ROOT = Path(__file__).resolve().parents[4] -ENV = dict(os.environ, PATH=f"{Path.home()}/.elan/bin:{Path.home()}/.local/bin:" + os.environ['PATH']) -def check(ok, message): +def lake_binary() -> str: + found = shutil.which("lake") + if found: + return found + version = (ROOT / "lean-toolchain").read_text().strip().split(":")[-1] + candidates = [ + Path.home() / ".elan/toolchains" / f"leanprover--lean4---{version}" / "bin/lake", + Path("/home/claudine/.hermes/profiles/claudine/home/.elan/toolchains") + / f"leanprover--lean4---{version}" / "bin/lake", + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + raise RuntimeError("lake executable not found") + + +LAKE = lake_binary() +ENV = dict(os.environ) + + +def check(ok: bool, message: str) -> None: if not ok: raise AssertionError(message) - print('PASS ' + message, flush=True) + print("PASS " + message, flush=True) + +def run(root: Path, args: list[str], success: bool = True, contains: str | None = None) -> str: + process = subprocess.run( + args, cwd=root, env=ENV, text=True, capture_output=True, timeout=300 + ) + output = process.stdout + process.stderr + if (process.returncode == 0) != success or (contains and contains not in output) or "PANIC" in output: + raise AssertionError(f"{args}: exit {process.returncode}\n{output}") + return output -def run(root, args, success=True, contains=None): - p = subprocess.run(args, cwd=root, env=ENV, text=True, capture_output=True, timeout=180) - out = p.stdout + p.stderr - if (p.returncode == 0) != success or (contains and contains not in out) or 'PANIC' in out: - raise AssertionError(f'{args}: exit {p.returncode}\n{out}') - return out +def main() -> None: + importer_path = ROOT / "Contracts/VaultFromSolidity/Importer/Importer.lean" + importer_text = importer_path.read_text() + python_frontend = ROOT / "Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py" + check(not python_frontend.exists(), "no Python importer/frontend exists") + check("--standard-json" in importer_text and "--no-import-callback" in importer_text, + "Lean importer invokes pinned solc standard JSON with import callback disabled") + check("solcSha256" in importer_text and "compiler checksum mismatch" in importer_text, + "Lean importer enforces compiler checksum and version pin") + check('cmd := "/usr/bin/sha256sum"' in importer_text and 'cmd := "sha256sum"' not in importer_text, + "compiler checksum utility uses a fixed path, not PATH lookup") + check("translateExpr" in importer_text and "translateStmts" in importer_text, + "Solidity constructs have explicit Lean translation functions") + check(all(tag not in importer_text for tag in ('[\"read\"', '[\"write\"', '[\"guard\"')), + "no custom serialized JSON IR tags") -def main(): - with tempfile.TemporaryDirectory(prefix='verity-vault-check-', dir=ROOT.parent) as directory: + with tempfile.TemporaryDirectory(prefix="verity-vault-check-", dir=ROOT.parent) as directory: root = Path(directory) - # Copy mutable build outputs (never hardlink); share only prebuilt dependencies. - for name in ('Verity', 'Compiler', 'Contracts', 'scripts', 'examples/solidity'): + for name in ("Verity", "Compiler", "Contracts", "scripts"): shutil.copytree(ROOT / name, root / name) - for name in ('lakefile.lean', 'lake-manifest.json', 'lean-toolchain'): + for name in ("lakefile.lean", "lake-manifest.json", "lean-toolchain"): shutil.copy2(ROOT / name, root / name) - for name in ('build', 'solidity-import'): - shutil.copytree(ROOT / '.lake' / name, root / '.lake' / name) - (root / '.lake/packages').symlink_to(ROOT / '.lake/packages', target_is_directory=True) - source = root / 'Contracts/VaultFromSolidity/Vault.sol' + shutil.copytree(ROOT / ".lake/build", root / ".lake/build") + (root / ".lake/solidity-import").mkdir(parents=True) + shutil.copy2(ROOT / ".lake/solidity-import/solc", root / ".lake/solidity-import/solc") + (root / ".lake/packages").symlink_to(ROOT / ".lake/packages", target_is_directory=True) + + source = root / "Contracts/VaultFromSolidity/Vault.sol" original = source.read_bytes() - stamp = source.stat() - frontend = root / 'Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py' - frontend_original = frontend.read_bytes() - compiler = root / '.lake/solidity-import/solc' + source_stamp = source.stat() + importer = root / "Contracts/VaultFromSolidity/Importer/Importer.lean" + importer_original = importer.read_bytes() + importer_stamp = importer.stat() + compiler = root / ".lake/solidity-import/solc" compiler_original = compiler.read_bytes() - lean_sources = set(root.rglob('*.lean')) - def edit(data): + lean_sources = set(root.rglob("*.lean")) + + def edit_source(data: bytes) -> None: source.write_bytes(data) - os.utime(source, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) - def build(success=True, contains=None): - return run(root, ['lake', 'build', 'VaultFromSolidity'], success, contains) - def model(success=True, contains=None): - return run(root, ['python3', str(frontend), str(source)], success, contains) - def artifacts(): - return {str(p.relative_to(root)): (p.stat().st_mtime_ns, hashlib.sha256(p.read_bytes()).hexdigest()) - for p in (root / '.lake/build/lib/lean/Contracts/VaultFromSolidity').rglob('*.olean')} - def caches(): - return {p.name: (p.stat().st_mtime_ns, p.read_bytes()) for p in compiler.parent.glob('*.json')} - build() - check(True, 'baseline lake build VaultFromSolidity') - proof = root / 'Contracts/VaultFromSolidity/Proofs/Execution.lean' - theorem_names = re.findall(r'^theorem\s+(\w+)', proof.read_text(), re.M) - audit_file = root / '.lake/solidity-import/AxiomAudit.lean' - try: - audit_file.write_text('import Contracts.VaultFromSolidity.Proofs.Execution\n' + - '\n'.join('#print axioms Contracts.VaultFromSolidity.Proofs.Execution.' + name - for name in theorem_names) + '\n') - audit = run(root, ['lake', 'env', 'lean', str(audit_file)]) - finally: - audit_file.unlink(missing_ok=True) - entries = re.findall( - r"'Contracts.VaultFromSolidity.Proofs.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", - audit) - check(set(theorem_names) == {name for name, _ in entries}, 'every theorem appears in actual #print axioms output') - axioms = {a.strip() for _, values in entries for a in values.split(',') if a.strip()} - check(axioms <= {'propext', 'Quot.sound', 'Classical.choice'}, 'no project axioms or sorryAx: ' + ', '.join(sorted(axioms))) - before, cached = artifacts(), caches() - first = model() - build() - check(before == artifacts(), 'unchanged Lake rebuild reuses all Vault oleans') - check(first == model() and cached == caches(), 'unchanged frontend reuses identical AST cache without rewriting') - cache_probe = """import runpy, subprocess, sys -original = subprocess.Popen -class Guard(subprocess.Popen): - def __init__(self, args, *a, **kw): - assert '--standard-json' not in args, 'unexpected solc compilation on cache hit' - super().__init__(args, *a, **kw) -subprocess.Popen = Guard -sys.argv = sys.argv[1:] -runpy.run_path(sys.argv[0], run_name='__main__') -""" - check(run(root, ['python3', '-c', cache_probe, str(frontend), str(source)]) == first, - 'cached frontend does not invoke solc --standard-json (subprocess guard)') - # Mutate compiler AST only in memory; exercise the same schema gate as main. - ast_probe = """import copy, importlib.util, json, pathlib, sys -spec = importlib.util.spec_from_file_location('frontend', sys.argv[1]) -m = importlib.util.module_from_spec(spec) -spec.loader.exec_module(m) -records = [json.loads(p.read_text()) for p in pathlib.Path(sys.argv[2]).glob('*.json')] -ast = next(r['output']['sources'][m.SOURCE]['ast'] for r in records - if r['output']['sources'][m.SOURCE]['ast']['nodes'][-1]['name'] == 'Vault') -def need(ok, n, why): - if not ok: raise ValueError(why) -m.validate_ast(ast, need) # existing structured documentation is legitimate -for mutation in ('unknown child', 'altered block', 'metadata child'): - changed = copy.deepcopy(ast) - contract = next(n for n in changed['nodes'] if n['nodeType'] == 'ContractDefinition') - f = next(n for n in contract['nodes'] if n['nodeType'] == 'FunctionDefinition') - if mutation == 'unknown child': f['body']['unexpectedExecutable'] = copy.deepcopy(f['body']['statements'][0]) - elif mutation == 'altered block': f['body']['nodeType'] = 'UncheckedBlock' - else: contract['documentation']['unexpectedExecutable'] = copy.deepcopy(f['body']) - try: m.validate_ast(changed, need) - except ValueError: print('rejected ' + mutation) - else: raise AssertionError('accepted ' + mutation) -""" - probe = run(root, ['python3', '-c', ast_probe, str(frontend), str(compiler.parent)]) - check(all('rejected ' + name in probe for name in ('unknown child', 'altered block', 'metadata child')), - 'closed recursive AST schema rejects unknown executable children and altered body kind; accepts documentation') - # Even the registered source must remain inside the canonical package root. - with tempfile.TemporaryDirectory(prefix='verity-vault-outside-', dir=ROOT.parent) as outside: - escaped = Path(outside) / 'Vault.sol' - escaped.write_bytes(original) - source.unlink() - source.symlink_to(escaped) + os.utime(source, ns=(source_stamp.st_atime_ns, source_stamp.st_mtime_ns)) + + def build(success: bool = True, contains: str | None = None) -> str: + return run(root, [LAKE, "build", "VaultFromSolidity"], success, contains) + + def artifacts() -> dict[str, tuple[int, str]]: + paths = list((root / ".lake/build/lib/lean/Contracts/VaultFromSolidity").rglob("*.olean")) + return { + str(path.relative_to(root)): ( + path.stat().st_mtime_ns, + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + for path in paths + } + + def source_digest() -> str: + digest_probe = root / ".lake/solidity-import/SourceDigestProbe.lean" try: - model(False, 'source outside package') - check(True, 'registered-source symlink escape rejected in temporary sandbox') + digest_probe.write_text( + "import Contracts.VaultFromSolidity.VaultFromSolidity\n" + "#eval Contracts.VaultFromSolidity.sourceDigest\n" + ) + output = run(root, [LAKE, "env", "lean", str(digest_probe)]) + match = re.search(r'"([0-9a-f]{64})"', output) + if match is None: + raise AssertionError("sourceDigest is not an auditable SHA-256 value") + check(True, "sourceDigest is an auditable SHA-256 value") + return match.group(1) finally: - source.unlink() - edit(original) - baseline_model = json.loads(first) - edit(original.replace(b'assets', b'depositAmount')) - model() - build() - check(True, 'parameter rename and references preserve existing proofs') - edit(original) - build() - for name, old, new in ( - ('deposit behavior', b'totalSupply += assets;', b'totalSupply = assets;'), - ('getter behavior', b'return shareBalances[account];', b'return totalAssets;'), - ): - check(original.count(old) == 1, name + ' mutation has one source target') - before = artifacts() - edit(original.replace(old, new)) - changed_model = json.loads(model()) - check(changed_model['functions'] != baseline_model['functions'], name + ' changes accepted AST behavior') - out = build(False, 'Contracts.VaultFromSolidity.Proofs.Execution') - check('unsolved goals' in out or 'Type mismatch' in out or 'type mismatch' in out, - name + ' preserved-mtime source edit rebuilds and breaks existing proof') - check(before != artifacts(), name + ' refreshes dependent oleans') - edit(original) - build() - for name, old, new, diagnostic in ( - ('contract layout at', b'contract Vault {', b'contract Vault layout at 100 {', 'layout at'), - ('initializer', b'uint256 public totalAssets;', b'uint256 public totalAssets = 1;', 'initializer'), - ('unchecked block', b'totalAssets += assets;', b'unchecked { totalAssets += assets; }', 'UncheckedBlock'), - ('loop', b'totalAssets += assets;', b'while (assets < totalAssets) { totalAssets += assets; }', 'WhileStatement'), - ('second contract', b'contract Vault {', b'contract Other {}\ncontract Vault {', 'exactly one'), - ('multiplication', b'totalAssets += assets;', b'totalAssets = totalAssets * assets;', 'unsupported binary'), - ): - edit(original.replace(old, new)) - output = model(False, diagnostic) - check(re.search(r'Contracts/VaultFromSolidity/Vault.sol:\d+:\d+:', output) is not None, - name + ' rejected with source location') - # Exercise an import failure through Lake as well as the frontend. - build(False, 'unsupported binary') - check(True, 'unsupported source cannot reuse prior successful Lake artifact') - edit(original) - build() - before = artifacts() - frontend.write_bytes(frontend_original + b'\n# acceptance invalidation probe\n') - build() - check(before != artifacts(), 'Python importer content change invalidates Vault oleans') - check(json.loads(model())['digest'] != baseline_model['digest'], 'importer change updates sourceDigest') - frontend.write_bytes(frontend_original) + digest_probe.unlink(missing_ok=True) + build() - lean_importer = root / 'Contracts/VaultFromSolidity/Importer/SolidityImporter.lean' - lean_original = lean_importer.read_bytes() + check(True, "baseline lake build VaultFromSolidity") + + ux_probe = root / "Contracts/VaultFromSolidity/FrontendProbe.lean" try: - lean_importer.write_bytes(lean_original + b'\n-- acceptance translation identity probe\n') - check(json.loads(model())['digest'] != baseline_model['digest'], - 'Lean translation implementation change updates sourceDigest') - before = artifacts() - build() - check(before != artifacts(), 'Lean importer change invalidates Vault oleans') + ux_probe.write_text('''import Contracts.VaultFromSolidity.Importer.Importer +solidity_contract Vault from "Vault.sol" +#print Vault.deposit +''') + ux_output = run(root, [LAKE, "env", "lean", str(ux_probe)]) + check("def Vault.deposit" in ux_output and "Verity.setStorage" in ux_output, + "documented solidity_contract Vault UX exposes #print Vault.deposit") finally: - lean_importer.write_bytes(lean_original) - build() - before_cache = caches() - frontend.write_bytes(frontend_original.replace(b"optimizer={'enabled': False}", b"optimizer={'enabled': True}")) - build() - check(before_cache.keys() != caches().keys(), 'compiler settings change creates a distinct AST cache entry') - frontend.write_bytes(frontend_original) - build() - policy = root / 'lakefile.lean' - policy_original = policy.read_bytes() - before = artifacts() - policy.write_bytes(policy_original + b'\n-- acceptance build-policy probe\n') - build() - check(before != artifacts(), 'build-policy content change invalidates Vault oleans') - policy.write_bytes(policy_original) - build() - # Appended data preserves executable format but violates the pinned binary hash. - compiler_stamp = compiler.stat() - compiler.write_bytes(compiler_original + b'\nacceptance-check\n') - os.utime(compiler, ns=(compiler_stamp.st_atime_ns, compiler_stamp.st_mtime_ns)) - build(False, 'compiler checksum mismatch') - check(True, 'compiler content change invalidates Lake and fails closed') - compiler.write_bytes(compiler_original) - build() - # Authored diagnostic snippets, not generated semantic/model source. - probe_file = root / '.lake/solidity-import/RegistrationProbe.lean' + ux_probe.unlink(missing_ok=True) + + proof = root / "Contracts/VaultFromSolidity/Proofs/Execution.lean" + proof_text = proof.read_text() + theorem_names = re.findall(r"^theorem\s+(\w+)", proof_text, re.M) + audit_file = root / ".lake/solidity-import/AxiomAudit.lean" try: - probe_file.write_text('''import Contracts.VaultFromSolidity.VaultFromSolidity + audit_file.write_text( + "import Contracts.VaultFromSolidity.Proofs.Execution\n" + + "\n".join( + "#print axioms Contracts.VaultFromSolidity.Proofs.Execution." + name + for name in theorem_names + ) + + "\n" + ) + audit = run(root, [LAKE, "env", "lean", str(audit_file)]) + finally: + audit_file.unlink(missing_ok=True) + entries = re.findall( + r"'Contracts.VaultFromSolidity.Proofs.Execution.(\w+)' depends on axioms: \[([^\]]*)\]", audit + ) + check(set(theorem_names) == {name for name, _ in entries}, + "every theorem appears in actual #print axioms output") + axioms = {a.strip() for _, values in entries for a in values.split(",") if a.strip()} + check(axioms <= {"propext", "Quot.sound", "Classical.choice"}, + "no project axioms or sorryAx: " + ", ".join(sorted(axioms))) + + probe = root / ".lake/solidity-import/RegistrationProbe.lean" + try: + probe.write_text('''import Contracts.VaultFromSolidity.VaultFromSolidity open Lean Elab Command +#print Contracts.VaultFromSolidity.deposit run_cmd do for suffix in ["totalAssetsSlot", "totalSupplySlot", "shareBalancesSlot", "deposit", "withdraw", "balanceOf", "totalAssets", "totalSupply", @@ -244,7 +191,8 @@ def need(ok, n, why): let original ← getEnv let mut rejected := false try - SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Existing):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) + SolidityImporter.elabSolidityContract + (← `(command| solidity_contract $(mkIdent `Existing):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) catch _ => rejected := true unless rejected do throwError "duplicate alias accepted" let some (.defnInfo before) := original.find? `Existing.deposit @@ -255,25 +203,193 @@ def need(ok, n, why): throwError "duplicate alias changed prior declaration" logInfo "DUPLICATE_ALIAS_REJECTED" ''') - result = run(root, ['lake', 'env', 'lean', str(probe_file)]) - check('CHECKED_TRANSPARENT_DECLARATIONS' in result and 'DUPLICATE_ALIAS_REJECTED' in result, - 'all ten imported declarations safe/transparent/closed; duplicate alias rejected without overwrite') - # Corrupt the type of a late declaration, after slots/getters were - # registered. The real command must synchronously catch the kernel - # error and restore the entire pre-import environment. - lean_original = lean_importer.read_bytes() + output = run(root, [LAKE, "env", "lean", str(probe)]) + check("CHECKED_TRANSPARENT_DECLARATIONS" in output and + "DUPLICATE_ALIAS_REJECTED" in output, + "safe transparent readable definitions and collision rollback") + check("Verity.setMapping" in output and "Verity.setStorage" in output and + "safeAdd" in output, + "#print deposit exposes readable source-derived behavior") + finally: + probe.unlink(missing_ok=True) + + before = artifacts() + build() + check(before == artifacts(), "unchanged build reuses Vault artifacts") + + theorem_starts = [ + (match.group(1), line_no) + for line_no, line in enumerate(proof_text.splitlines(), 1) + if (match := re.match(r"theorem\s+([A-Za-z0-9_']+)", line.strip())) + ] + theorem_ranges = { + name: (start, theorem_starts[index + 1][1] - 1 if index + 1 < len(theorem_starts) + else len(proof_text.splitlines())) + for index, (name, start) in enumerate(theorem_starts) + } + + edit_source(original.replace(b"assets", b"depositAmount")) + build() + check(True, "declaration-ID based parameter rename preserves proofs") + edit_source(original) + build() + + for name, theorem, old, new in ( + ("deposit behavior", "deposit_meets_spec", b"totalSupply += assets;", b"totalSupply = assets;"), + ("getter behavior", "balance_meets_spec", b"return shareBalances[account];", b"return totalAssets;"), + ): + check(original.count(old) == 1, name + " mutation has one source target") + before = artifacts() + edit_source(original.replace(old, new)) + output = build(False, "Contracts.VaultFromSolidity.Proofs.Execution") + error_lines = [int(value) for value in re.findall( + r"Contracts/VaultFromSolidity/Proofs/Execution\.lean:(\d+):", output)] + start, end = theorem_ranges[theorem] + check(any(start <= line <= end for line in error_lines), + name + f" mutation breaks {theorem}") + check(before != artifacts(), name + " preserved-mtime edit refreshes artifacts") + edit_source(original) + build() + + for name, old, new, diagnostic in ( + ("contract layout at", b"contract Vault {", b"contract Vault layout at 100 {", "layout at"), + ("initializer", b"uint256 public totalAssets;", b"uint256 public totalAssets = 1;", "initializer"), + ("unchecked block", b"totalAssets += assets;", b"unchecked { totalAssets += assets; }", "UncheckedBlock"), + ("loop", b"totalAssets += assets;", b"while (assets < totalAssets) { totalAssets += assets; }", "WhileStatement"), + ("second contract", b"contract Vault {", b"contract Other {}\ncontract Vault {", "exactly one"), + ("multiplication", b"totalAssets += assets;", b"totalAssets = totalAssets * assets;", "unsupported binary"), + ): + edit_source(original.replace(old, new)) + output = build(False, diagnostic) + check(re.search(r"Contracts/VaultFromSolidity/Vault.sol:\d+:\d+:", output) is not None, + name + " rejected with source position") + edit_source(original) + build() + + with tempfile.TemporaryDirectory(prefix="verity-vault-outside-", dir=ROOT.parent) as outside: + escaped = Path(outside) / "Vault.sol" + escaped.write_bytes(original) + source.unlink() + source.symlink_to(escaped) + try: + probe.write_text('''import Contracts.VaultFromSolidity.Importer.Importer +solidity_contract Escaped from "../../Contracts/VaultFromSolidity/Vault.sol" +''') + run(root, [LAKE, "env", "lean", str(probe)], False, "source outside package") + check(True, "registered-source symlink escape rejected") + finally: + probe.unlink(missing_ok=True) + source.unlink() + edit_source(original) + build() + + before = artifacts() + digest_before = source_digest() + importer.write_bytes(importer_original + b"\n-- acceptance translation identity probe\n") + os.utime(importer, ns=(importer_stamp.st_atime_ns, importer_stamp.st_mtime_ns)) + build() + check(before != artifacts(), "Lean importer content change invalidates Vault artifacts") + check(digest_before != source_digest(), "Lean importer content changes sourceDigest") + importer.write_bytes(importer_original) + build() + + policy = root / "lakefile.lean" + policy_original = policy.read_bytes() + before = artifacts() + policy.write_bytes(policy_original + b"\n-- acceptance build-policy probe\n") + build() + check(before != artifacts(), "build-policy content change invalidates Vault artifacts") + policy.write_bytes(policy_original) + build() + + compiler_stamp = compiler.stat() + compiler.write_bytes(compiler_original + b"\nacceptance-check\n") + os.utime(compiler, ns=(compiler_stamp.st_atime_ns, compiler_stamp.st_mtime_ns)) + build(False, "compiler checksum mismatch") + check(True, "compiler content mutation fails closed") + compiler.write_bytes(compiler_original) + build() + + # Synthetic compiler-output probes test the JSON boundary itself. The + # temporary wrapper is checksummed and accepted only in this disposable + # package; production still executes the pinned binary directly. + real_compiler = compiler.with_name("solc-real") + pin = b"1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468" + for mode, diagnostic in ( + ("ast", "unexpected AST fields"), + ("metadata", "unexpected AST fields"), + ("typed", "invalid AST metadata field Assignment.isLValue"), + ("missing", "missing AST fields"), + ("span", "source span outside registered source"), + ("layout", "missing/packed layout"), + ): try: - lean_importer.write_bytes(lean_original.replace( - b' type := type\n', - b' type := if name.toString.endsWith ".deposit" then mkConst ``Nat else type\n')) - run(root, ['lake', 'build', 'VaultSolidityImporter']) - probe_file.write_text('''import Contracts.VaultFromSolidity.Importer.SolidityImporter + real_compiler.write_bytes(compiler_original) + real_compiler.chmod(0o755) + compiler.write_text(f'''#!/usr/bin/env python3 +import json, pathlib, subprocess, sys +real = pathlib.Path(__file__).with_name("solc-real") +p = subprocess.run([str(real), *sys.argv[1:]], input=sys.stdin.buffer.read(), capture_output=True) +if "--standard-json" not in sys.argv: + sys.stdout.buffer.write(p.stdout); sys.stderr.buffer.write(p.stderr); raise SystemExit(p.returncode) +o = json.loads(p.stdout) +if {mode!r} in ("ast", "metadata", "typed", "missing", "span"): + def mutate(x): + if isinstance(x, dict): + if {mode!r} == "ast" and x.get("nodeType") == "Assignment": + x["unknownExecutableField"] = True; return True + if {mode!r} == "metadata" and x.get("nodeType") == "StructuredDocumentation": + x["unexpectedExecutable"] = {{"nodeType": "UncheckedBlock", "id": 999999, "src": "0:0:0"}} + return True + if {mode!r} == "typed" and x.get("nodeType") == "Assignment": + x["isLValue"] = {{"nodeType": "UncheckedBlock", "id": 999999, "src": "0:0:0"}} + return True + if {mode!r} == "missing" and x.get("nodeType") == "Assignment": + del x["isPure"] + return True + if {mode!r} == "span" and x.get("nodeType") == "Assignment": + start, size, _ = x["src"].split(":") + x["src"] = f"{{start}}:{{size}}:999" + return True + return any(mutate(v) for v in x.values()) + if isinstance(x, list): return any(mutate(v) for v in x) + return False + assert mutate(o) +else: + o["contracts"]["Contracts/VaultFromSolidity/Vault.sol"]["Vault"]["storageLayout"]["storage"][0]["offset"] = 1 +sys.stdout.write(json.dumps(o)) +''') + compiler.chmod(0o755) + wrapper_hash = hashlib.sha256(compiler.read_bytes()).hexdigest().encode() + check(pin in importer_original, "compiler pin occurs in Lean importer") + importer.write_bytes(importer_original.replace(pin, wrapper_hash)) + run(root, [LAKE, "build", "VaultSolidityImporter"]) + build(False, diagnostic) + check(True, f"synthetic {mode} compiler output fails closed") + finally: + real_compiler.unlink(missing_ok=True) + compiler.write_bytes(compiler_original) + compiler.chmod(0o755) + importer.write_bytes(importer_original) + build() + + # Corrupt a late declaration's type. Synchronous checking must reject it + # before any declaration from the failed namespace escapes the transaction. + try: + importer.write_bytes(importer_original.replace( + b" addDecl (.defnDecl { name, levelParams := [], type, value, hints := .regular 0, safety := .safe })", + b' let type := if name.toString.endsWith ".deposit" then mkConst ``Nat else type\n' + b" addDecl (.defnDecl { name, levelParams := [], type, value, hints := .regular 0, safety := .safe })", + )) + run(root, [LAKE, "build", "VaultSolidityImporter"]) + probe.write_text('''import Contracts.VaultFromSolidity.Importer.Importer open Lean Elab Command set_option Elab.async true run_cmd do let mut rejected := false try - SolidityImporter.elabSolidityContract (← `(command| solidity_contract $(mkIdent `Broken):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) + SolidityImporter.elabSolidityContract + (← `(command| solidity_contract $(mkIdent `Broken):ident from "../../Contracts/VaultFromSolidity/Vault.sol")) catch e => rejected := true logInfo m!"EXPECTED_KERNEL_ERROR {e.toMessageData}" @@ -281,49 +397,23 @@ def need(ok, n, why): for suffix in ["totalAssetsSlot", "totalSupplySlot", "shareBalancesSlot", "totalAssets", "totalSupply", "shareBalances", "deposit", "sourceDigest"] do if (← getEnv).contains (`Broken ++ Name.mkSimple suffix) then - throwError "partial/fallback declaration escaped rollback: {suffix}" + throwError "partial declaration escaped rollback: {suffix}" logInfo "KERNEL_REJECTION_ROLLED_BACK" ''') - result = run(root, ['lake', 'env', 'lean', str(probe_file)]) - check('KERNEL_REJECTION_ROLLED_BACK' in result and '(kernel)' in result, - 'malformed late declaration rejected synchronously; no partial definitions or fallback axioms escape') - finally: - lean_importer.write_bytes(lean_original) - build() - finally: - probe_file.unlink(missing_ok=True) - # Deliberately corrupt the frontend return metadata without replacing a - # body: Lean must reject the inconsistent typed interface before export. - try: - frontend.write_bytes(frontend_original.replace( - b'dict(name=name, params=params, returns=returns, body=', - b"dict(name=name, params=params, returns='unit', body=")) - build(False, 'imported body does not match typed AST return signature') - check(True, 'inconsistent typed return metadata rejected before declaration export') + output = run(root, [LAKE, "env", "lean", str(probe)]) + check("KERNEL_REJECTION_ROLLED_BACK" in output and "(kernel)" in output, + "malformed late declaration rejected synchronously with full rollback") finally: - frontend.write_bytes(frontend_original) + probe.unlink(missing_ok=True) + importer.write_bytes(importer_original) build() - # Cold compiler cache, with new sockets denied for the whole process tree. - # strace is an explicit test prerequisite, not needed by normal imports. - for path in compiler.parent.glob('*.json'): - path.unlink() - # Force only the imported wrapper to elaborate again, keeping prerequisites. - for path in (root / '.lake/build/lib/lean/Contracts/VaultFromSolidity').glob('VaultFromSolidity.*'): - path.unlink() - if platform.system() == 'Linux': - run(root, ['strace', '-f', '-e', 'inject=socket:error=EPERM', '-o', - str(root / '.lake/solidity-import/offline.trace'), - 'lake', 'build', 'VaultFromSolidity']) - check(True, 'Linux cold-cache build succeeds with new network sockets denied') - else: - build() - check(True, 'cold-cache build succeeds (socket-denial probe is Linux-only)') - check(bool(caches()), 'cold AST cache and current-source Lake build succeed') - check(set(root.rglob('*.lean')) == lean_sources, 'no generated model .lean files') - check(source.read_bytes() == original and frontend.read_bytes() == frontend_original, - 'temporary source and importer restored; final baseline build passes') - print(f'PASS all Vault acceptance checks ({len(theorem_names)} audited theorems)', flush=True) + + check(set(root.rglob("*.lean")) == lean_sources, + "no generated model .lean files") + check(source.read_bytes() == original and importer.read_bytes() == importer_original, + "temporary mutations restored; final baseline passes") + print(f"PASS all Lean-only Vault acceptance checks ({len(theorem_names)} audited theorems)", flush=True) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/Contracts/VaultFromSolidity/README.md b/Contracts/VaultFromSolidity/README.md index ac63c7bb81..13828fbe12 100644 --- a/Contracts/VaultFromSolidity/README.md +++ b/Contracts/VaultFromSolidity/README.md @@ -9,8 +9,7 @@ the handwritten `Contracts/Vault` example. | File | Role | | --- | --- | | `Vault.sol` | The original Solidity implementation. | -| `Importer/scripts/solidity_importer.py` | Runs pinned solc, validates the supported AST and emits structured JSON. | -| `Importer/SolidityImporter.lean` | Implements `solidity_contract` and registers checked Verity definitions in Lean. | +| `Importer/Importer.lean` | Runs pinned solc, validates typed AST/storage layout, translates, and registers checked Verity definitions. | | `VaultFromSolidity.lean` | Points the importer at `Vault.sol`. | | `Spec.lean` | Human-written requirements for the imported contract. | | `Proofs/Execution.lean` | Proofs that the imported contract satisfies those requirements. | @@ -24,10 +23,11 @@ the handwritten `Contracts/Vault` example. 4. Prove it in `Proofs/Execution.lean`. 5. Run `lake build VaultFromSolidity` and reload the Lean editor after Solidity changes. -The importer asks pinned solc for the typed AST and storage layout. The Python -frontend rejects unsupported constructs and emits a small JSON model. The Lean -importer converts that model into transparent, kernel-checked `Verity.Contract` -definitions directly in memory. It does not generate model `.lean` files or +`Importer.lean` invokes pinned solc with `--standard-json` and +`--no-import-callback`, then parses and validates its typed AST and storage +layout in Lean. Explicit `translateExpr` / `translateStmt` cases construct +transparent, kernel-checked `Verity.Contract` definitions directly in memory. +There is no Python frontend, custom serialized IR, generated Lean source, or bytecode. The current proof of concept accepts only this registered Vault and a deliberately diff --git a/Contracts/VaultFromSolidity/VaultFromSolidity.lean b/Contracts/VaultFromSolidity/VaultFromSolidity.lean index 21bd5b70bd..b0050628c1 100644 --- a/Contracts/VaultFromSolidity/VaultFromSolidity.lean +++ b/Contracts/VaultFromSolidity/VaultFromSolidity.lean @@ -1,4 +1,4 @@ -import Contracts.VaultFromSolidity.Importer.SolidityImporter +import Contracts.VaultFromSolidity.Importer.Importer namespace Contracts diff --git a/README.md b/README.md index 661d0beae8..bba60b2656 100644 --- a/README.md +++ b/README.md @@ -25,30 +25,28 @@ `Contracts/VaultFromSolidity/VaultFromSolidity.lean` imports the colocated `Vault.sol` with `solidity_contract VaultFromSolidity from "Vault.sol"`. -The frontend requests typed AST and storage -layout from pinned solc 0.8.33, then registers transparent, kernel-checked -`Verity.Contract` definitions directly in memory. There is no generated model -`.lean`, CompilationModel, or bytecode. The example is independent of the +The Lean frontend invokes pinned solc 0.8.33 for typed AST and storage layout, +validates and translates them directly, then registers transparent, +kernel-checked `Verity.Contract` definitions in memory. There is no Python +frontend, custom serialized IR, generated `.lean`, CompilationModel, or +bytecode. The example is independent of the handwritten `Contracts/Vault` contract. See the [Vault-from-Solidity walkthrough](Contracts/VaultFromSolidity/README.md). -With the Lean/package prerequisites installed, put the pinned solc 0.8.33 binary -at `.lake/solidity-import/solc` and make it executable. Accepted official SHA-256 -digests are `1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468` -for Linux amd64 and -`8324280591ce398d7e2722846bc10ecf1779b13a328ef97b687c92cd9c70801a` -for macOS amd64, then run: +With the Lean/package prerequisites installed, put the official Linux-amd64 solc +0.8.33 binary at `.lake/solidity-import/solc` and make it executable. Its accepted +SHA-256 digest is +`1274e5c4621ae478090c5a1f48466fd3c5f658ed9e14b15a0b213dc806215468`, then run: ```sh lake build VaultFromSolidity python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py ``` -The acceptance script uses disposable copies for source mutations, rejection, -content-based Lake freshness, cache reuse, compiler/importer invalidation, and -an audit of every Vault theorem. It also tests declaration-registration rollback -and cold-cache builds. On Linux it also denies new sockets with `strace`; normal -imports do not require `strace`. It never mutates the original Solidity file. +The acceptance script uses disposable copies for source mutations, fail-closed +rejection, content-based Lake freshness, compiler/importer/build-policy +invalidation, declaration-registration rollback, and an audit of every Vault +theorem. It never mutates the original Solidity file. Save Solidity, rebuild this dedicated target, then reload the Lean editor: an already-open editor snapshot does not automatically watch `.sol` changes. See [the trust boundary](TRUST_ASSUMPTIONS.md#proof-only-solidity-vault-import). diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index fa884bec8a..ffd16cc1da 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -5,24 +5,33 @@ This document states what Verity proves and what it still trusts. ## Proof-only Solidity Vault import This POC is separate from the verified compilation pipeline below. It trusts -pinned solc's typed AST/storage layout, the colocated Python frontend, and -`Contracts/VaultFromSolidity/Importer/SolidityImporter.lean` translation to -preserve Solidity meaning. Kernel -checking establishes well-typed definitions and theorems about their execution, -not a Solidity-to-Verity equivalence theorem. `sourceDigest` is provenance, not -proof of correspondence. It hashes the compiler input/output, Python frontend, -Lean translation implementation, and verified solc checksum/version. It is not -full build identity: transitive Verity semantics, Lean toolchain, and Lake build -policy are tracked separately by normal build dependencies, not this digest. -The recursive closed AST schema permits explicitly typed documentation and -compiler metadata, but rejects unknown fields/node kinds and contract `layout at`. -Canonical package containment is checked independently of source registration. -Declaration registration disables asynchronous kernel checking inside the import -transaction, restores the pre-import environment on failure, and checks each -body against its typed return signature before registration. Safe transparent -definitions are also compiled by Lean for ordinary executable consumers. -Local AST caches are trusted build artifacts: their -self-recorded hashes detect accidental corruption, not malicious replacement. +pinned solc's typed AST/storage layout and the Lean translation in +`Contracts/VaultFromSolidity/Importer/Importer.lean` to preserve Solidity +meaning. Kernel checking establishes well-typed definitions and theorems about +their execution, not a Solidity-to-Verity equivalence theorem. `sourceDigest` +is provenance, not proof of correspondence. It hashes the compiler +input/output, Lean importer implementation, and verified solc checksum/version. +The Linux host's fixed `/usr/bin/sha256sum` is trusted for compiler-pin checks; +the digest is checked before version inspection, immediately before compilation, +and again after compilation, so `PATH` substitution and persistent compiler +replacement fail closed. As with all local builds, a concurrently malicious +process with the builder's own filesystem privileges is outside the threat model. +It is not full build identity: transitive Verity semantics, Lean toolchain, and +Lake build policy are tracked separately by normal build dependencies, not this +digest. The recursive closed AST schema rejects unknown fields/node kinds and +contract `layout at`; semantically used type metadata and all storage-layout +records are checked explicitly. Canonical package containment is checked +independently of source registration. + +`Importer.lean` runs pinned solc itself with `--standard-json` and +`--no-import-callback`, parses the typed AST/storage layout, validates the +closed subset, resolves IDs/types/storage slots, and constructs expressions +through explicit `translateExpr` / `translateStmt` cases. Declaration +registration disables asynchronous kernel checking inside the transaction, +restores the pre-import environment on failure, checks every body against its +typed return signature, and registers safe transparent definitions. The +frontend emits no generated Lean source and keeps no serialized AST/model +cache. The accepted fragment covers the existing Vault: full-width scalars, address-to-uint256 mappings and public getters, straight-line reads/writes, @@ -42,11 +51,12 @@ arithmetic panic strings remain a model representation, not an assertion of matching EVM revert bytes. The statements do not assert full equivalence of all executions or all public/deployment interfaces. -Lake's dedicated `VaultFromSolidity` target tracks source/compiler/Python-and-Lean-importer/build +Lake's dedicated `VaultFromSolidity` target tracks source/compiler/Lean-importer/build policy bytes and normal Lean dependencies. Acceptance evidence is obtained with `python3 Contracts/VaultFromSolidity/Importer/scripts/solidity_importer_test.py`; -stale editor snapshots are not a -current-source proof certificate. No additional project axiom is introduced. +that Python file only orchestrates disposable builds and mutations and is not in +the translation path. Stale editor snapshots are not a current-source proof +certificate. No additional project axiom is introduced. ## Compilation Pipeline diff --git a/artifacts/trust_surface_report.json b/artifacts/trust_surface_report.json index a724e456a0..f94530f0b7 100644 --- a/artifacts/trust_surface_report.json +++ b/artifacts/trust_surface_report.json @@ -169,7 +169,7 @@ "mechanisms": { "@[implemented_by": 1, "native_decide": 584, - "partial def": 178 + "partial def": 180 }, "notes": "native_decide trusts Lean.ofReduceBool or Lean 4.31 generated per-proof native_decide axioms + Lean.trustCompiler. Prose registry: AXIOMS.md, TRUST_ASSUMPTIONS.md (enforced by scripts/check_trust_surface_registry.py).", "schema_version": 1 diff --git a/lakefile.lean b/lakefile.lean index e6b0ced724..ce4192c8c7 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -26,12 +26,8 @@ input_file vaultSolidity where path := "Contracts/VaultFromSolidity/Vault.sol" text := false -input_file vaultFrontend where - path := "Contracts/VaultFromSolidity/Importer/scripts/solidity_importer.py" - text := false - input_file vaultLeanImporter where - path := "Contracts/VaultFromSolidity/Importer/SolidityImporter.lean" + path := "Contracts/VaultFromSolidity/Importer/Importer.lean" text := false input_file vaultSolc where @@ -43,13 +39,13 @@ input_file vaultBuildPolicy where text := false lean_lib «VaultSolidityImporter» where - globs := #[.one `Contracts.VaultFromSolidity.Importer.SolidityImporter] + globs := #[.one `Contracts.VaultFromSolidity.Importer.Importer] lean_lib «VaultFromSolidity» where globs := #[.one `Contracts.VaultFromSolidity.VaultFromSolidity, .one `Contracts.VaultFromSolidity.Spec, .one `Contracts.VaultFromSolidity.Proofs.Execution] - needs := #[vaultSolidity, vaultFrontend, vaultLeanImporter, vaultSolc, vaultBuildPolicy] + needs := #[vaultSolidity, vaultLeanImporter, vaultSolc, vaultBuildPolicy] lean_lib «Contracts» where globs := #[ From e30674178c8f8f5980ff459256a0818499c987ca Mon Sep 17 00:00:00 2001 From: fricoben Date: Fri, 11 Sep 2026 15:32:29 +0200 Subject: [PATCH 8/8] refactor: focus Vault proofs on solvency Keep one readable example for the imported Solidity Vault: the exact post-state of each entry point plus the vault solvency invariant (totalAssets = totalSupply) preserved by deposit and withdraw. Drop the getter, nonpayable, revert-path, frame and rollback theorems, which the exact post-state specs subsume or the acceptance suite already exercises. Remove the example README; the root README now points at Spec.lean and Proofs/Execution.lean directly. Refresh derived artifacts for 5 theorems. --- .../VaultFromSolidity/Proofs/Execution.lean | 122 ++++++------------ Contracts/VaultFromSolidity/README.md | 36 ------ Contracts/VaultFromSolidity/Spec.lean | 52 ++++---- PrintAxioms.lean | 16 +-- README.md | 5 +- TRUST_ASSUMPTIONS.md | 8 +- artifacts/verification_status.json | 12 +- docs-site/public/llms.txt | 2 +- docs/VERIFICATION_STATUS.md | 14 +- test/property_exclusions.json | 14 +- test/property_manifest.json | 14 +- 11 files changed, 94 insertions(+), 201 deletions(-) delete mode 100644 Contracts/VaultFromSolidity/README.md diff --git a/Contracts/VaultFromSolidity/Proofs/Execution.lean b/Contracts/VaultFromSolidity/Proofs/Execution.lean index 3819bdcdd2..be3f6f54ac 100644 --- a/Contracts/VaultFromSolidity/Proofs/Execution.lean +++ b/Contracts/VaultFromSolidity/Proofs/Execution.lean @@ -1,5 +1,20 @@ import Contracts.VaultFromSolidity.Spec +/-! +# Proofs about the Vault imported from Solidity + +Two layers, deliberately kept small: + +1. `*_meets_spec` -- each entry point produces exactly the state + `Spec` describes. These unfold the definitions `Importer.lean` registered + from `Vault.sol`, so they fail if the Solidity source changes behaviour. +2. `*_preserves_solvency` -- the contract-level result. Neither a deposit nor a + withdrawal can break the one-for-one backing between assets and issued + shares. A reverting call leaves the state untouched (`Contract.run` rolls + back), so solvency can only ever be lost on a successful call, which is what + these two theorems rule out. +-/ + namespace Contracts.VaultFromSolidity.Proofs.Execution open Verity open Verity.Stdlib.Math @@ -15,6 +30,8 @@ macro "reduce_vault" : tactic => `(tactic| ContractState.readSlot, ContractState.writeSlot, ContractState.readMap, ContractState.writeMap, ContractState.storage, ContractState.storageMap]) +/-! ## Exact behaviour of each entry point -/ + theorem balance_meets_spec (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : Spec.balance_execution s account := by reduce_vault @@ -34,103 +51,40 @@ theorem withdraw_meets_spec (s : ContractState) (amount : Uint256) (ht : amount.val ≤ (s.readSlot 1).val) : Spec.withdraw_execution s amount := by reduce_vault -theorem deposit_nonpayable (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (deposit amount).run s = ContractResult.revert "Nonpayable" s := by - reduce_vault +/-! ## The vault stays solvent -/ -/-- A late failing addition rolls back the earlier mapping and asset writes. -/ -theorem deposit_late_overflow_rollback (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = none) : - (deposit amount).run s = ContractResult.revert "Panic(0x11)" s := by - reduce_vault - -theorem withdraw_insufficient_shares (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : (s.readMap 2 s.sender).val < amount.val) : - (withdraw amount).run s = ContractResult.revert "InsufficientShares()" s := by - reduce_vault - -/-- Successful deposit changes no unrelated logical storage key. -/ -theorem deposit_frame (s : ContractState) (amount : Uint256) +/-- A successful deposit credits the caller's shares and both totals by the same +amount, so assets still exactly back the issued shares. -/ +theorem deposit_preserves_solvency (s : ContractState) (amount : Uint256) (h0 : s.msgValue = 0) (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) - (key : Verity.StorageKey) (hk0 : key ≠ .slot 0) (hk1 : key ≠ .slot 1) - (hkm : key ≠ .map 2 s.sender) : - ((deposit amount).run s).snd.storageWords key = s.storageWords key := by + (hsolvent : Spec.solvent s) : + Spec.solvent ((deposit amount).run s).snd := by have h := deposit_meets_spec s amount h0 hs ha ht rw [Spec.deposit_execution] at h rw [h] - simp [Spec.accountingState, ContractState.writeSlot, ContractState.writeMap, hk0, hk1, hkm] - -theorem totalAssets_getter (s : ContractState) (h0 : s.msgValue = 0) : - totalAssets.run s = ContractResult.success (s.readSlot 0) s := by - reduce_vault - -theorem totalSupply_getter (s : ContractState) (h0 : s.msgValue = 0) : - totalSupply.run s = ContractResult.success (s.readSlot 1) s := by - reduce_vault - -theorem shareBalances_getter (s : ContractState) (account : Address) (h0 : s.msgValue = 0) : - (shareBalances account).run s = ContractResult.success (s.readMap 2 account) s := by - reduce_vault - -theorem withdraw_nonpayable (s : ContractState) (amount : Uint256) - (h0 : s.msgValue.val ≠ 0) : - (withdraw amount).run s = ContractResult.revert "Nonpayable" s := by - reduce_vault - -theorem withdraw_insufficient_assets (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : (s.readSlot 0).val < amount.val) : - (withdraw amount).run s = ContractResult.revert "InsufficientAssets()" s := by - reduce_vault - -theorem withdraw_insufficient_supply (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) - (ha : amount.val ≤ (s.readSlot 0).val) (ht : (s.readSlot 1).val < amount.val) : - (withdraw amount).run s = ContractResult.revert "InsufficientSupply()" s := by - reduce_vault - -theorem deposit_existing_spec (s : ContractState) (amount : Uint256) - (h0 : s.msgValue = 0) - (hs : safeAdd (s.readMap 2 s.sender) amount = some (s.readMap 2 s.sender + amount)) - (ha : safeAdd (s.readSlot 0) amount = some (s.readSlot 0 + amount)) - (ht : safeAdd (s.readSlot 1) amount = some (s.readSlot 1 + amount)) : - Spec.deposit_spec amount s ((deposit amount).run s).snd := by - have h := deposit_meets_spec s amount h0 hs ha ht - rw [Spec.deposit_execution] at h - rw [h] - simp +contextual [Spec.deposit_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, - Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, - Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, - Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, - ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, - ContractState.storage, ContractState.storageMap, - Verity.EVM.Uint256.add] - repeat' constructor - all_goals exact Verity.Core.Uint256.add_comm _ _ - -theorem withdraw_existing_spec (s : ContractState) (amount : Uint256) + simp only [Spec.solvent, Spec.accountingState, ContractResult.snd, + ContractState.readSlot, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage] at hsolvent ⊢ + simp [hsolvent] + +/-- A successful withdrawal debits the caller's shares and both totals by the +same amount, so assets still exactly back the issued shares. -/ +theorem withdraw_preserves_solvency (s : ContractState) (amount : Uint256) (h0 : s.msgValue = 0) (hs : amount.val ≤ (s.readMap 2 s.sender).val) (ha : amount.val ≤ (s.readSlot 0).val) - (ht : amount.val ≤ (s.readSlot 1).val) : - Spec.withdraw_spec amount s ((withdraw amount).run s).snd := by + (ht : amount.val ≤ (s.readSlot 1).val) + (hsolvent : Spec.solvent s) : + Spec.solvent ((withdraw amount).run s).snd := by have h := withdraw_meets_spec s amount h0 hs ha ht rw [Spec.withdraw_execution] at h rw [h] - simp +contextual [Spec.withdraw_spec, Spec.accountingState, Spec.sameStorageExceptAssetSlots, - Spec.storageUnchangedExceptAssetSlots, Specs.sameStorageAddr, Specs.sameContext, - Specs.storageMapUnchangedExceptKeyAtSlot, Specs.storageMapUnchangedExceptKey, - Specs.storageMapUnchangedExceptSlot, ContractResult.snd, ContractState.readSlot, - ContractState.readMap, ContractState.writeSlot, ContractState.writeMap, - ContractState.storage, ContractState.storageMap, - Verity.EVM.Uint256.sub] - repeat' constructor + simp only [Spec.solvent, Spec.accountingState, ContractResult.snd, + ContractState.readSlot, ContractState.writeSlot, ContractState.writeMap, + ContractState.storage] at hsolvent ⊢ + simp [hsolvent] end Contracts.VaultFromSolidity.Proofs.Execution diff --git a/Contracts/VaultFromSolidity/README.md b/Contracts/VaultFromSolidity/README.md deleted file mode 100644 index 13828fbe12..0000000000 --- a/Contracts/VaultFromSolidity/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Vault from Solidity - -This self-contained example imports an existing Solidity contract into Lean and -proves properties directly about the imported definitions. It does not depend on -the handwritten `Contracts/Vault` example. - -## File map - -| File | Role | -| --- | --- | -| `Vault.sol` | The original Solidity implementation. | -| `Importer/Importer.lean` | Runs pinned solc, validates typed AST/storage layout, translates, and registers checked Verity definitions. | -| `VaultFromSolidity.lean` | Points the importer at `Vault.sol`. | -| `Spec.lean` | Human-written requirements for the imported contract. | -| `Proofs/Execution.lean` | Proofs that the imported contract satisfies those requirements. | -| `Importer/scripts/solidity_importer_test.py` | Maintainer acceptance tests; not a developer translation step. | - -## Developer workflow - -1. Keep or edit `Vault.sol`. -2. Declare its import in `VaultFromSolidity.lean`. -3. Write the required behavior in `Spec.lean`. -4. Prove it in `Proofs/Execution.lean`. -5. Run `lake build VaultFromSolidity` and reload the Lean editor after Solidity changes. - -`Importer.lean` invokes pinned solc with `--standard-json` and -`--no-import-callback`, then parses and validates its typed AST and storage -layout in Lean. Explicit `translateExpr` / `translateStmt` cases construct -transparent, kernel-checked `Verity.Contract` definitions directly in memory. -There is no Python frontend, custom serialized IR, generated Lean source, or -bytecode. - -The current proof of concept accepts only this registered Vault and a deliberately -small Solidity subset. The importer remains a trusted translation boundary: the -Lean kernel proves the stated properties of the imported definitions, not a -general Solidity-to-Verity or bytecode equivalence theorem. diff --git a/Contracts/VaultFromSolidity/Spec.lean b/Contracts/VaultFromSolidity/Spec.lean index 765be020e9..3c7e05dcf8 100644 --- a/Contracts/VaultFromSolidity/Spec.lean +++ b/Contracts/VaultFromSolidity/Spec.lean @@ -1,37 +1,37 @@ -import Verity.Specs.Common -import Verity.Specs.Common.Sum import Verity.EVM.Uint256 import Contracts.VaultFromSolidity.VaultFromSolidity +/-! +# What the imported Vault is supposed to do + +`Importer.lean` turns `Vault.sol` into ordinary Verity definitions: `deposit`, +`withdraw`, `balanceOf`, and one `StorageSlot` per state variable +(slot `0 = totalAssets`, slot `1 = totalSupply`, slot `2 = shareBalances`). + +This file states two things about them: + +* `solvent` -- the property that matters for the contract as a whole. Shares are + issued one-for-one against assets, so every share outstanding must stay backed + by an asset the vault accounts for. +* `deposit_execution` / `withdraw_execution` / `balance_execution` -- the exact + state each entry point produces. These pin down behaviour precisely enough to + derive `solvent`, and they are what a Solidity mutation has to break. +-/ + namespace Contracts.VaultFromSolidity.Spec open Verity -open Verity.Specs open Verity.EVM.Uint256 -def storageUnchangedExceptAssetSlots (s s' : ContractState) : Prop := - ∀ slotIdx : Nat, slotIdx ≠ 0 → slotIdx ≠ 1 → s'.storage slotIdx = s.storage slotIdx - -def sameStorageExceptAssetSlots (s s' : ContractState) : Prop := - storageUnchangedExceptAssetSlots s s' ∧ - Specs.sameStorageAddr s s' ∧ - Specs.sameContext s s' - -def deposit_spec (assets : Uint256) (s s' : ContractState) : Prop := - s'.storageMap 2 s.sender = add (s.storageMap 2 s.sender) assets ∧ - s'.storage 0 = add (s.storage 0) assets ∧ - s'.storage 1 = add (s.storage 1) assets ∧ - Specs.storageMapUnchangedExceptKeyAtSlot 2 s.sender s s' ∧ - sameStorageExceptAssetSlots s s' - -def withdraw_spec (shares : Uint256) (s s' : ContractState) : Prop := - s'.storageMap 2 s.sender = sub (s.storageMap 2 s.sender) shares ∧ - s'.storage 0 = sub (s.storage 0) shares ∧ - s'.storage 1 = sub (s.storage 1) shares ∧ - Specs.storageMapUnchangedExceptKeyAtSlot 2 s.sender s s' ∧ - sameStorageExceptAssetSlots s s' - -/-- Exact post-state, including Verity's ghost key-enumeration metadata. -/ +/-- The vault's main invariant: issued shares are exactly backed by assets +(`totalAssets = totalSupply`). If this ever breaks, shares stop being +redeemable one-for-one and the vault is insolvent. -/ +def solvent (s : ContractState) : Prop := + s.readSlot 0 = s.readSlot 1 + +/-- Exact post-state of a successful `deposit`/`withdraw`: the caller's share +balance and both totals move together, including Verity's ghost +key-enumeration metadata. -/ def accountingState (s : ContractState) (shares assets supply : Uint256) : ContractState := let mapped := { s.writeMap 2 s.sender shares with knownAddresses := fun slotIdx => if slotIdx == 2 then diff --git a/PrintAxioms.lean b/PrintAxioms.lean index 17e1b29a49..ddc580fa9c 100644 --- a/PrintAxioms.lean +++ b/PrintAxioms.lean @@ -692,18 +692,8 @@ end Verity.AxiomAudit Contracts.VaultFromSolidity.Proofs.Execution.balance_meets_spec Contracts.VaultFromSolidity.Proofs.Execution.deposit_meets_spec Contracts.VaultFromSolidity.Proofs.Execution.withdraw_meets_spec - Contracts.VaultFromSolidity.Proofs.Execution.deposit_nonpayable - Contracts.VaultFromSolidity.Proofs.Execution.deposit_late_overflow_rollback - Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_shares - Contracts.VaultFromSolidity.Proofs.Execution.deposit_frame - Contracts.VaultFromSolidity.Proofs.Execution.totalAssets_getter - Contracts.VaultFromSolidity.Proofs.Execution.totalSupply_getter - Contracts.VaultFromSolidity.Proofs.Execution.shareBalances_getter - Contracts.VaultFromSolidity.Proofs.Execution.withdraw_nonpayable - Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_assets - Contracts.VaultFromSolidity.Proofs.Execution.withdraw_insufficient_supply - Contracts.VaultFromSolidity.Proofs.Execution.deposit_existing_spec - Contracts.VaultFromSolidity.Proofs.Execution.withdraw_existing_spec + Contracts.VaultFromSolidity.Proofs.Execution.deposit_preserves_solvency + Contracts.VaultFromSolidity.Proofs.Execution.withdraw_preserves_solvency -- Verity/Proofs/CheckedExternalCallConsumer.lean Verity.Proofs.CheckedExternalCallConsumer.lido_submit_entry_installs_caller_context @@ -7533,4 +7523,4 @@ end Verity.AxiomAudit Compiler.Proofs.YulGeneration.YulTransaction.ofIR_args ] --- Total: 6968 theorems/lemmas (4978 public, 1990 private, 0 sorry'd) +-- Total: 6958 theorems/lemmas (4968 public, 1990 private, 0 sorry'd) diff --git a/README.md b/README.md index bba60b2656..93120319bc 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ validates and translates them directly, then registers transparent, kernel-checked `Verity.Contract` definitions in memory. There is no Python frontend, custom serialized IR, generated `.lean`, CompilationModel, or bytecode. The example is independent of the -handwritten `Contracts/Vault` contract. See the -[Vault-from-Solidity walkthrough](Contracts/VaultFromSolidity/README.md). +handwritten `Contracts/Vault` contract. `Spec.lean` states the vault's solvency +invariant plus the exact post-state of each entry point, and +`Proofs/Execution.lean` proves them against the imported definitions. With the Lean/package prerequisites installed, put the official Linux-amd64 solc 0.8.33 binary at `.lake/solidity-import/solc` and make it executable. Its accepted diff --git a/TRUST_ASSUMPTIONS.md b/TRUST_ASSUMPTIONS.md index ffd16cc1da..d180ca0250 100644 --- a/TRUST_ASSUMPTIONS.md +++ b/TRUST_ASSUMPTIONS.md @@ -42,8 +42,12 @@ failed executions; errors are model strings, not verified ABI revert bytes. The storage model uses logical keys, not a proof of physical keccak layout. There is no deployment, calldata/dispatch, gas, external interaction, bytecode, or full EVM equivalence claim. Initial states are arbitrary, not proven deployed -states. Arithmetic success premises restrict success theorems; separate failure -proofs cover nonpayability, insufficient balances and late-overflow rollback. +states. Arithmetic success premises restrict the success theorems. The example keeps one +readable proof set: the exact post-state of each entry point plus the vault's +solvency invariant (`totalAssets = totalSupply`) preserved by deposit and +withdrawal. Revert-path behaviour (nonpayability, insufficient +shares/assets/supply, late-overflow rollback) is exercised by the acceptance +suite, not proved here. The specification and execution proof file refer directly to the imported definitions. Zero-argument custom errors use Verity's `Name()` model convention; diff --git a/artifacts/verification_status.json b/artifacts/verification_status.json index 0d856ccb20..b93a6f999e 100644 --- a/artifacts/verification_status.json +++ b/artifacts/verification_status.json @@ -16,10 +16,10 @@ }, "theorems": { "categories": 16, - "coverage_percent": 74, + "coverage_percent": 76, "covered": 255, - "excluded": 89, - "non_stdlib_total": 344, + "excluded": 79, + "non_stdlib_total": 334, "per_contract": { "Counter": 31, "ERC20": 22, @@ -36,11 +36,11 @@ "SimpleStorage": 20, "SimpleToken": 61, "Vault": 9, - "VaultFromSolidity": 15 + "VaultFromSolidity": 5 }, - "proven": 344, + "proven": 334, "stdlib": 0, - "total": 344 + "total": 334 }, "toolchain": { "lean": "leanprover/lean4:v4.31.0", diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index df35f72363..5abfb5b45d 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -33,7 +33,7 @@ Every transition inside the proof envelope is either fully verified or recorded - **Language**: Lean 4.31.0 - **Core Size**: 2040 lines - **Verified Contracts**: 16 (Counter, ERC20, ERC721, Ledger, LocalObligationMacroSmoke, Ownable, Owned, OwnedCounter, OwnedCounterComposed, ReentrancyExample, ReentrancyRelyGuarantee, SafeCounter, SimpleStorage, SimpleToken, Vault, VaultFromSolidity) -- **Theorems**: 344 across 16 categories, 344 fully proven +- **Theorems**: 334 across 16 categories, 334 fully proven - **Axioms**: 1 documented Lean axioms (see AXIOMS.md) - **Tests**: 528 Foundry tests, 239 property tests - **Build**: `lake build` verifies all proofs diff --git a/docs/VERIFICATION_STATUS.md b/docs/VERIFICATION_STATUS.md index 08f23c7278..ad95f36772 100644 --- a/docs/VERIFICATION_STATUS.md +++ b/docs/VERIFICATION_STATUS.md @@ -39,13 +39,13 @@ EVM Bytecode | ERC20 | 22 | Baseline | `Contracts/ERC20/Proofs/` | | ERC721 | 11 | Baseline | `Contracts/ERC721/Proofs/` | | Vault | 9 | Baseline | `Contracts/Vault/Proofs/` | -| VaultFromSolidity | 15 | Proof-only import | `Contracts/VaultFromSolidity/Proofs/` | +| VaultFromSolidity | 5 | Proof-only import | `Contracts/VaultFromSolidity/Proofs/` | | ReentrancyExample | 5 | Complete | `Contracts/ReentrancyExample/Contract.lean` | | ReentrancyRelyGuarantee | 10 | Semantic | `Contracts/ReentrancyRelyGuarantee/Contract.lean` | | CryptoHash | 0 | No specs | `Contracts/CryptoHash/Contract.lean` | -| **Total** | **344** | **✅ 100%** | — | +| **Total** | **334** | **✅ 100%** | — | -> **Note**: Stdlib (0 internal proof-automation properties) is excluded from the contract-spec theorem table above but included in overall coverage statistics (344 total properties). +> **Note**: Stdlib (0 internal proof-automation properties) is excluded from the contract-spec theorem table above but included in overall coverage statistics (334 total properties). Layer 1 uses macro-generated EDSL-to-`CompilationModel` bridge theorems backed by a generic typed-IR compilation-correctness theorem ([`TypedIRCompilerCorrectness.lean`](../Compiler/TypedIRCompilerCorrectness.lean)). Tuple/bytes/fixed-array/dynamic-array/string parameters now stay inside that proof path when they are carried as ABI head words/offsets. Advanced constructs beyond that typed-IR head-word surface (linked libraries, ECMs, fully custom ABI behavior) are still expressed directly in `CompilationModel` and trusted at that boundary. Higher-order internal helpers (function-pointer parameters, [#1747](https://github.com/lfglabs-dev/verity/issues/1747)) are eliminated by a compile-time monomorphization pre-pass that runs before any lowering, so the `CompilationModel` only ever contains first-order helpers: these calls are covered by the existing first-order proof path and introduce no new boundary trust. @@ -203,7 +203,7 @@ Also note that the macro-generated `*_semantic_preservation` theorems are not co |----------|----------|------------| | ERC20 | 86% (19/22) | 3 proof-only | | Vault | 0% (0/9) | 9 proof-only | -| VaultFromSolidity | 0% (0/15) | 15 proof-only | +| VaultFromSolidity | 0% (0/5) | 5 proof-only | | ERC721 | 100% (11/11) | 0 | | SafeCounter | 100% (25/25) | 0 | | ReentrancyExample | 100% (5/5) | 0 | @@ -219,11 +219,11 @@ Also note that the macro-generated `*_semantic_preservation` theorems are not co | Counter | 74% (23/31) | 8 proof-only | | Stdlib | 0% (0/0) | 0 proof-only | -**Status**: 74% coverage (255/344), 89 remaining exclusions all proof-only +**Status**: 76% coverage (255/334), 79 remaining exclusions all proof-only -- **Total Properties**: 344 +- **Total Properties**: 334 - **Covered**: 255 -- **Excluded**: 89 (all proof-only) +- **Excluded**: 79 (all proof-only) **Proof-Only Properties (74 exclusions)**: Internal proof machinery that cannot be tested in Foundry. diff --git a/test/property_exclusions.json b/test/property_exclusions.json index d687ae8cf7..e8d020fa34 100644 --- a/test/property_exclusions.json +++ b/test/property_exclusions.json @@ -17,20 +17,10 @@ ], "VaultFromSolidity": [ "balance_meets_spec", - "deposit_existing_spec", - "deposit_frame", - "deposit_late_overflow_rollback", "deposit_meets_spec", - "deposit_nonpayable", - "shareBalances_getter", - "totalAssets_getter", - "totalSupply_getter", - "withdraw_existing_spec", - "withdraw_insufficient_assets", - "withdraw_insufficient_shares", - "withdraw_insufficient_supply", + "deposit_preserves_solvency", "withdraw_meets_spec", - "withdraw_nonpayable" + "withdraw_preserves_solvency" ], "Counter": [ "getStorage_reads_count", diff --git a/test/property_manifest.json b/test/property_manifest.json index 63a0db7013..db7b8830a2 100644 --- a/test/property_manifest.json +++ b/test/property_manifest.json @@ -360,19 +360,9 @@ ], "VaultFromSolidity": [ "balance_meets_spec", - "deposit_existing_spec", - "deposit_frame", - "deposit_late_overflow_rollback", "deposit_meets_spec", - "deposit_nonpayable", - "shareBalances_getter", - "totalAssets_getter", - "totalSupply_getter", - "withdraw_existing_spec", - "withdraw_insufficient_assets", - "withdraw_insufficient_shares", - "withdraw_insufficient_supply", + "deposit_preserves_solvency", "withdraw_meets_spec", - "withdraw_nonpayable" + "withdraw_preserves_solvency" ] }