Skip to content

Commit 7d0ae8b

Browse files
committed
Harden formal audit trust gates
1 parent d71bf23 commit 7d0ae8b

15 files changed

Lines changed: 409 additions & 43 deletions

.github/workflows/verify.yml

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -399,36 +399,8 @@ jobs:
399399
- name: Build split Lake packages independently
400400
run: python3 scripts/check_split_package_builds.py
401401

402-
- name: Check for axioms in proof files
403-
run: |
404-
echo "Checking for axioms in Compiler/ and Verity/..."
405-
grep -rnE "^(private |protected )?axiom " Compiler/ Verity/ --include="*.lean" | tee axioms.txt || true
406-
AXIOM_COUNT=$(wc -l < axioms.txt 2>/dev/null | tr -d ' ')
407-
echo "Found $AXIOM_COUNT axiom declaration(s)"
408-
if [ "$AXIOM_COUNT" -gt 0 ]; then
409-
echo "::warning::Found $AXIOM_COUNT axiom(s) in proof files"
410-
if [ ! -f "AXIOMS.md" ]; then
411-
echo "::error::AXIOMS.md not found. All axioms must be documented."
412-
exit 1
413-
fi
414-
AXIOM_NAMES=$(sed -E 's/.*:(private |protected )?axiom ([a-zA-Z0-9_]*).*/\2/' axioms.txt | sort -u)
415-
UNDOCUMENTED=""
416-
for axiom_name in $AXIOM_NAMES; do
417-
if ! grep -q "$axiom_name" AXIOMS.md; then
418-
echo "::error::Axiom '$axiom_name' not documented in AXIOMS.md"
419-
UNDOCUMENTED="$UNDOCUMENTED $axiom_name"
420-
else
421-
echo "✓ Axiom '$axiom_name' is documented"
422-
fi
423-
done
424-
if [ -n "$UNDOCUMENTED" ]; then
425-
echo "::error::Undocumented axioms:$UNDOCUMENTED"
426-
exit 1
427-
fi
428-
echo "✓ All $AXIOM_COUNT axioms are documented in AXIOMS.md"
429-
else
430-
echo "✓ No axioms found in proof files"
431-
fi
402+
- name: Check documented Lean axioms
403+
run: python3 scripts/check_axioms.py
432404

433405
- name: Build proof modules for axiom audit
434406
run: lake build PrintAxioms

AXIOMS.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ axiom solidityMappingSlot_lt_evmModulus (baseSlot key : Nat) :
4545
**Runtime performance**: An `@[implemented_by]` annotation optionally redirects to
4646
the FFI version at runtime for speed, without affecting proof soundness.
4747

48+
## Trusted Reduction and Codegen Surface (Non-Axiom)
49+
50+
Verity currently has zero project-level Lean axioms, but some proofs and tests
51+
intentionally rely on Lean mechanisms that sit outside ordinary kernel
52+
elaboration:
53+
54+
- `native_decide` proofs depend on Lean's native code generation and the builtin
55+
`Lean.ofReduceBool` axiom. They are acceptable for executable smoke tests,
56+
concrete bridge checks, and explicitly documented reduction witnesses, but
57+
they are tracked as trusted reduction surface rather than counted as
58+
project-level axioms.
59+
- `@[implemented_by ...]` may redirect runtime execution to a faster
60+
implementation. The proof term still sees the kernel-computable definition,
61+
so this is a runtime/codegen trust boundary rather than a Lean axiom.
62+
- `partial def` marks recursive executable helpers whose termination is not
63+
kernel-proved. These helpers are allowed in macro, codegen, reporting, and
64+
native-test infrastructure, and their presence is registry-gated.
65+
66+
CI runs `scripts/check_trust_surface_registry.py` to ensure these mechanisms and
67+
all explicit ECM assumptions (`axioms := [...]`) remain documented when their
68+
source footprint changes.
69+
4870
### 2. Selector computation (eliminated earlier)
4971

5072
Function selector derivation (`bytes4(keccak256(signature))`) was previously
@@ -166,6 +188,14 @@ compilation fail if any assumption hasn't been reviewed.
166188
| `Precompiles.bn256Pairing` | `evm_bn256_pairing_precompile` | EVM precompile at address 0x08 behaves per EIP-197 (BN254 optimal-Ate pairing) |
167189
| `Callbacks.callback` | `callback_target_interface` | Callback target processes ABI-encoded arguments correctly |
168190
| `Calls.withReturn` | `external_call_abi_interface` | Target contract function matches declared selector and ABI |
191+
| `Hashing.abiEncodeDynamicArrayStaticElements` | `abi_standard_dynamic_array_static_element_layout` | Memory layout matches Solidity standard ABI encoding for one dynamic array of fixed-width static elements |
192+
193+
### Test-Only ECM Assumptions
194+
195+
| Fixture | Assumption | Meaning |
196+
|---------|------------|---------|
197+
| `Compiler.CompileDriverTest` | `test_call_interface` | Synthetic compile-driver fixture for ECM trust reporting |
198+
| `Compiler.CompileDriverTest` | `ctor_hook_interface` | Synthetic constructor fixture for ECM trust reporting |
169199

170200
### Third-Party Module Assumptions
171201

Compiler/CompilationModelFeatureTest.lean

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,27 @@ example : hashSliceExecutableUsesRuntimeStub = true := by native_decide
230230

231231
end MacroKeccakSmoke
232232

233+
namespace MappingWordSmoke
234+
235+
open Contracts.Smoke
236+
237+
example :
238+
MappingWordSmoke.setWord1_modelBody =
239+
[ Stmt.setMappingWord "words" (Expr.param "key") 1 (Expr.param "value"),
240+
Stmt.stop ] := rfl
241+
242+
example :
243+
MappingWordSmoke.getWord1_modelBody =
244+
[ Stmt.letVar "word" (Expr.mappingWord "words" (Expr.param "key") 1),
245+
Stmt.return (Expr.localVar "word") ] := rfl
246+
247+
example :
248+
MappingWordSmoke.isWord1NonZero_modelBody =
249+
[ Stmt.letVar "word" (Expr.mappingWord "words" (Expr.param "key") 1),
250+
Stmt.return (Expr.logicalNot (Expr.eq (Expr.localVar "word") (Expr.literal 0))) ] := rfl
251+
252+
end MappingWordSmoke
253+
233254
namespace MacroExternalSmoke
234255

235256
open Contracts
@@ -4960,10 +4981,11 @@ set_option maxRecDepth 4096 in
49604981
expectCompileToYul "abiEncodeStaticArray smoke spec" abiEncodeStaticArraySmokeSpec
49614982
expectTrue "abiEncodeStaticArray writes the single dynamic argument head and length"
49624983
(contains abiEncodeStaticArrayYul "mstore(__digest_abi_array_ptr, 32)" &&
4963-
contains abiEncodeStaticArrayYul "mstore(add(__digest_abi_array_ptr, 32), items_length)")
4984+
contains abiEncodeStaticArrayYul "let __digest_abi_array_length := items_length" &&
4985+
contains abiEncodeStaticArrayYul "mstore(add(__digest_abi_array_ptr, 32), __digest_abi_array_length)")
49644986
expectTrue "abiEncodeStaticArray copies the fixed-width element payload"
49654987
(contains abiEncodeStaticArrayYul
4966-
"let __digest_abi_array_data_bytes := mul(items_length, 128)" &&
4988+
"let __digest_abi_array_data_bytes := mul(__digest_abi_array_length, 128)" &&
49674989
contains abiEncodeStaticArrayYul
49684990
"calldatacopy(add(__digest_abi_array_ptr, 64), items_data_offset, __digest_abi_array_data_bytes)")
49694991
expectTrue "abiEncodeStaticArray hashes the ABI-encoded dynamic array byte length"

Compiler/Keccak/Sponge.lean

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,16 @@ def domainSuffix (v : HashVariant) : UInt8 :=
7171
/-- Pads the message tail (0 to 135 bytes) to a full 136-byte block. -/
7272
def padBlock (leftover : ByteArray) (variant : HashVariant) : ByteArray :=
7373
let rate := 136
74-
let arr1 := leftover.data.push (domainSuffix variant)
75-
let padLen := rate - arr1.size
76-
let arr2 := arr1 ++ Array.replicate padLen 0
77-
let lastIdx := rate - 1
78-
let lastVal := arr2[lastIdx]? |>.getD 0
79-
let arr3 := arr2.set! lastIdx (lastVal ||| 0x80)
80-
⟨arr3⟩
74+
if leftover.size >= rate then
75+
panic! "Keccak.padBlock: leftover block must be shorter than rate"
76+
else
77+
let arr1 := leftover.data.push (domainSuffix variant)
78+
let padLen := rate - arr1.size
79+
let arr2 := arr1 ++ Array.replicate padLen 0
80+
let lastIdx := rate - 1
81+
let lastVal := arr2[lastIdx]? |>.getD 0
82+
let arr3 := arr2.set! lastIdx (lastVal ||| 0x80)
83+
⟨arr3⟩
8184

8285
/-- Kernel-friendly evaluation of a single bitwise instruction. -/
8386
@[always_inline, inline]

Compiler/Modules/Hashing.lean

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,22 +139,25 @@ def abiEncodeStaticArrayModule
139139
if elementWords == 0 then
140140
throw "abiEncodeStaticArray requires elementWords > 0"
141141
let ptrName := s!"__{resultVar}_abi_array_ptr"
142+
let lengthName := s!"__{resultVar}_abi_array_length"
142143
let dataBytesName := s!"__{resultVar}_abi_array_data_bytes"
143144
let totalBytesName := s!"__{resultVar}_abi_array_total_bytes"
144145
let paddedTotalName := s!"__{resultVar}_abi_array_padded_total"
145146
let ptr := YulExpr.ident ptrName
147+
let length := YulExpr.ident lengthName
146148
let dataBytes := YulExpr.ident dataBytesName
147149
let totalBytes := YulExpr.ident totalBytesName
148150
pure [
149151
YulStmt.block ([
150152
YulStmt.let_ ptrName (YulExpr.call "mload" [YulExpr.lit freeMemoryPointer]),
153+
YulStmt.let_ lengthName arrayLengthExpr,
151154
YulStmt.expr (YulExpr.call "mstore" [ptr, YulExpr.lit 32]),
152155
YulStmt.expr (YulExpr.call "mstore" [
153156
YulExpr.call "add" [ptr, YulExpr.lit 32],
154-
arrayLengthExpr
157+
length
155158
]),
156159
YulStmt.let_ dataBytesName (YulExpr.call "mul" [
157-
arrayLengthExpr,
160+
length,
158161
YulExpr.lit (elementWords * 32)
159162
])
160163
] ++ ECM.dynamicCopyData ctx

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ check: ## Run local CI-equivalent checks job (no Lean build, no solc)
125125
python3 scripts/check_paths.py
126126
python3 scripts/check_compilationmodel_split.py
127127
python3 scripts/check_axioms.py
128+
python3 scripts/check_trust_surface_registry.py
129+
python3 scripts/check_benchmark_cases.py
128130
python3 scripts/generate_verification_status.py --check
129131
python3 scripts/generate_layer2_boundary_catalog.py --check
130132
python3 scripts/check_verification_status_doc.py

docs/EXTERNAL_CALL_MODULES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Standard modules ship in `Compiler/Modules/`:
9797
| `ERC20.safeTransferFrom` | `transferFrom(from, to, amount)` | ERC-20 transferFrom with optional-bool-return handling | `erc20_transferFrom_interface` |
9898
| `ERC20.safeApprove` | `approve(spender, amount)` | ERC-20 approve with optional-bool-return handling | `erc20_approve_interface` |
9999
| `Hashing.abiEncodePackedWords` / `Hashing.abiEncodePacked` | Static-word packed Keccak | Writes 32-byte words contiguously and binds `keccak256(0, wordCount * 32)` | `keccak256_memory_slice_matches_evm`, `abi_packed_static_word_layout` |
100+
| `Hashing.abiEncodeDynamicArrayStaticElements` | Standard dynamic-array Keccak | Writes ABI head offset, array length, and contiguous fixed-width static elements before hashing | `keccak256_memory_slice_matches_evm`, `abi_standard_dynamic_array_static_element_layout` |
100101
| `Hashing.sha256PackedWords` / `Hashing.sha256Packed` | Static-word packed SHA-256 | Writes 32-byte words contiguously, calls precompile 0x02, and binds digest word | `evm_sha256_precompile`, `abi_packed_static_word_layout` |
101102
| `Hashing.abiEncodePackedStaticSegments` | Static byte-width packed Keccak | Writes 1- to 32-byte static segments at byte-precise offsets and binds `keccak256(0, totalBytes)` | `keccak256_memory_slice_matches_evm`, `abi_packed_static_segment_layout` |
102103
| `Hashing.sha256PackedStaticSegments` | Static byte-width packed SHA-256 | Writes 1- to 32-byte static segments, calls precompile 0x02 over the exact byte length, and binds digest word | `evm_sha256_precompile`, `abi_packed_static_segment_layout` |

scripts/check_axioms.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from property_utils import ROOT, die, report_errors, scrub_lean_code
1919

2020
AXIOM_DECL_RE = re.compile(r"^(?:private |protected )?axiom\s+([A-Za-z_][A-Za-z0-9_']*)\b")
21+
DEFAULT_AXIOM_ROOTS = ("Compiler", "Verity", "Contracts", "Benchmark")
2122

2223
LEAN_BUILTIN_AXIOMS = frozenset([
2324
"propext",
@@ -56,7 +57,7 @@ def parse_active_axiom_count(axioms_md_text: str) -> int | None:
5657
def discover_repo_axioms() -> dict[str, tuple[str, int]]:
5758
"""Return all Lean axiom declarations as ``{name: (relative_path, line)}``."""
5859
discovered: dict[str, tuple[str, int]] = {}
59-
for subdir in ("Compiler", "Verity"):
60+
for subdir in DEFAULT_AXIOM_ROOTS:
6061
base_dir = ROOT / subdir
6162
if not base_dir.exists():
6263
continue

scripts/check_benchmark_cases.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env python3
2+
"""Validate optional benchmark-case metadata.
3+
4+
Core Verity checkouts do not normally contain `Benchmark/`; downstream
5+
benchmark repos may vendor or overlay `Benchmark/`, while the standalone
6+
benchmark repository keeps metadata under `cases/`. When present, each
7+
`case.yaml` is a publication boundary and must identify the upstream target
8+
commit. If a case has `FormalAudit.lean`, it must not still be marked
9+
`proof_status: not_started`.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from pathlib import Path
16+
17+
from property_utils import ROOT, report_errors
18+
19+
KEY_VALUE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$")
20+
21+
22+
def parse_case_yaml(path: Path) -> dict[str, str]:
23+
values: dict[str, str] = {}
24+
for line in path.read_text(encoding="utf-8").splitlines():
25+
line = line.split("#", 1)[0].strip()
26+
if not line:
27+
continue
28+
match = KEY_VALUE_RE.match(line)
29+
if match:
30+
values[match.group(1)] = match.group(2).strip().strip("\"'")
31+
return values
32+
33+
34+
def check_benchmark_cases(root: Path = ROOT) -> list[str]:
35+
case_files = sorted((root / "Benchmark").glob("Cases/**/case.yaml"))
36+
case_files += sorted((root / "cases").glob("**/case.yaml"))
37+
if not case_files:
38+
return []
39+
40+
errors: list[str] = []
41+
for case_yaml in case_files:
42+
metadata = parse_case_yaml(case_yaml)
43+
case_dir = case_yaml.parent
44+
rel = case_yaml.relative_to(root)
45+
46+
if not metadata.get("upstream_commit"):
47+
errors.append(f"{rel}: missing upstream_commit")
48+
49+
if (case_dir / "FormalAudit.lean").exists():
50+
proof_status = metadata.get("proof_status")
51+
if proof_status == "not_started":
52+
errors.append(f"{rel}: FormalAudit.lean exists but proof_status is not_started")
53+
54+
audit_target_commit = metadata.get("audit_target_commit")
55+
if audit_target_commit is not None and not re.fullmatch(r"[0-9a-fA-F]{7,40}", audit_target_commit):
56+
errors.append(f"{rel}: audit_target_commit must be a 7-40 character git hex prefix")
57+
58+
return errors
59+
60+
61+
def main() -> int:
62+
case_files = sorted((ROOT / "Benchmark").glob("Cases/**/case.yaml"))
63+
case_files += sorted((ROOT / "cases").glob("**/case.yaml"))
64+
if not case_files:
65+
print("OK: no benchmark case metadata found; benchmark metadata check skipped")
66+
return 0
67+
68+
errors = check_benchmark_cases()
69+
report_errors(errors, "Benchmark case metadata check failed")
70+
print(f"OK: benchmark metadata check passed for {len(case_files)} case.yaml file(s)")
71+
return 0
72+
73+
74+
if __name__ == "__main__":
75+
raise SystemExit(main())
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python3
2+
"""Validate that non-axiom trusted surfaces are documented.
3+
4+
The Lean kernel axiom registry is intentionally separate from Verity's broader
5+
trusted surface. This checker covers mechanisms that do not create project
6+
Lean axioms, but still matter for audit soundness:
7+
8+
* `native_decide`
9+
* `@[implemented_by ...]`
10+
* `partial def`
11+
* explicit ECM assumption strings in `axioms := [...]`
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import re
17+
from pathlib import Path
18+
19+
from property_utils import ROOT, report_errors, scrub_lean_code, strip_lean_comments
20+
21+
SCAN_ROOTS = ("Compiler", "Verity", "Contracts", "Benchmark")
22+
DOC_PATHS = ("AXIOMS.md", "TRUST_ASSUMPTIONS.md")
23+
DOC_DIRS = ("docs",)
24+
25+
ECM_AXIOM_RE = re.compile(r'axioms\s*:=\s*\[[^\]]*"([^"]+)"', re.MULTILINE)
26+
ECM_AXIOM_STRING_RE = re.compile(r'"([^"]+)"')
27+
28+
MECHANISM_PATTERNS = (
29+
("native_decide", re.compile(r"\bnative_decide\b")),
30+
("@[implemented_by", re.compile(r"@\[\s*implemented_by\b")),
31+
("partial def", re.compile(r"\bpartial\s+def\b")),
32+
)
33+
34+
35+
def _iter_lean_files(root: Path = ROOT) -> list[Path]:
36+
files: list[Path] = []
37+
for subdir in SCAN_ROOTS:
38+
base = root / subdir
39+
if base.exists():
40+
files.extend(sorted(base.rglob("*.lean")))
41+
return files
42+
43+
44+
def _read_doc_corpus(root: Path = ROOT) -> str:
45+
chunks: list[str] = []
46+
for rel in DOC_PATHS:
47+
path = root / rel
48+
if path.exists():
49+
chunks.append(path.read_text(encoding="utf-8"))
50+
for rel in DOC_DIRS:
51+
base = root / rel
52+
if base.exists():
53+
for path in sorted(base.rglob("*.md")):
54+
chunks.append(path.read_text(encoding="utf-8"))
55+
return "\n".join(chunks)
56+
57+
58+
def _extract_ecm_axioms(text: str) -> set[str]:
59+
names: set[str] = set()
60+
for match in re.finditer(r"axioms\s*:=\s*\[(.*?)\]", text, flags=re.DOTALL):
61+
names.update(ECM_AXIOM_STRING_RE.findall(match.group(1)))
62+
return names
63+
64+
65+
def collect_trust_surface(root: Path = ROOT) -> tuple[dict[str, int], dict[str, tuple[str, int]]]:
66+
mechanisms = {name: 0 for name, _ in MECHANISM_PATTERNS}
67+
ecm_axioms: dict[str, tuple[str, int]] = {}
68+
69+
for path in _iter_lean_files(root):
70+
raw_text = path.read_text(encoding="utf-8")
71+
text = scrub_lean_code(raw_text)
72+
text_without_comments = strip_lean_comments(raw_text)
73+
rel = str(path.relative_to(root))
74+
for name, pattern in MECHANISM_PATTERNS:
75+
mechanisms[name] += len(pattern.findall(text))
76+
for line_no, line in enumerate(text_without_comments.splitlines(), 1):
77+
for axiom_name in _extract_ecm_axioms(line):
78+
ecm_axioms.setdefault(axiom_name, (rel, line_no))
79+
80+
return mechanisms, ecm_axioms
81+
82+
83+
def main() -> int:
84+
docs = _read_doc_corpus()
85+
mechanisms, ecm_axioms = collect_trust_surface()
86+
87+
errors: list[str] = []
88+
for name, count in mechanisms.items():
89+
if count > 0 and name not in docs:
90+
errors.append(
91+
f"{name}: {count} occurrence(s) found in Lean sources but missing from trust docs"
92+
)
93+
94+
for axiom_name, (rel, line) in sorted(ecm_axioms.items()):
95+
if axiom_name not in docs:
96+
errors.append(
97+
f"{axiom_name}: ECM assumption from {rel}:{line} is missing from trust docs"
98+
)
99+
100+
report_errors(errors, "Trust-surface registry check failed")
101+
print(
102+
"OK: trust-surface registry documents "
103+
f"{len(ecm_axioms)} ECM assumption(s), "
104+
+ ", ".join(f"{name}={count}" for name, count in mechanisms.items())
105+
)
106+
return 0
107+
108+
109+
if __name__ == "__main__":
110+
raise SystemExit(main())

0 commit comments

Comments
 (0)