Lean 4 Reference

Lean 4 Theorem Proving

Theorem proving from zero -- tactics, proof state, axioms, and patterns used in the ENC specification.

20Sections
142Theorems
1sorry
54Modules
Section 01

What is a Proof?

A proposition is a type. A proof is a value of that type.

LeanPropositions as types
-- "1 + 1 = 2" is a type
-- A proof is a value that satisfies it
theorem two : 1 + 1 = 2 := by decide

If the proposition is true, you can build a proof. If false, you can't -- the file won't compile:

LeanBuild-time correctness
theorem ok : 1 + 1 = 2 := by decide        -- compiles
-- theorem bad : 1 + 1 = 3 := by decide    -- build error
Key idea: A theorem failing is not a test failure. It's a build failure. The code doesn't exist unless it's correct.

Section 02

The Infoview

Open it: Ctrl+Shift+P → "Lean 4: Infoview: Toggle"

It shows the proof state -- what you have and what you still need to prove. Place your cursor after each line to watch it change.

Proof StateInfoview display
n : Nat          ← hypotheses (what you have)
h : n > 5        ← hypotheses
⊢ n > 3          ← goal (what you need to prove)

Everything above is what you know. Below is what you must prove. Tactics transform this state until the goal is gone.

Always have Infoview open when reading or writing proofs.

Section 03

Levels of Proof

There are two kinds of proofs in the ENC spec. Understanding this split is the key to not getting confused.

Level 1: Computation -- "just check it"

The schema is concrete. All values are known. Lean can compute the answer:

LeanLevel 1 -- native_decide
def mySchema : Schema := { entries := [ ... ] }   -- concrete data

-- Lean evaluates permits at compile time and checks the result
theorem anyone_reads :
    mySchema.permits "Any" "Post" .R = true := by native_decide

native_decide = "evaluate the function, check the result." About 50 of the 142 theorems in the spec are pure native_decide.

Level 2: Reasoning -- "prove it step by step"

The inputs are abstract. You can't compute -- you must reason:

LeanLevel 2 -- manual reasoning
-- a and b are unknown. Can't evaluate sha256 (it's an axiom).
-- Must build a logical chain.
theorem H_injective :
    ∀ (a b : List CborField), H a = H b → a = b := by
  intro a b hab
  unfold H at hab
  have hcbor := sha256_injective _ _ hab
  exact cborEncode_injective _ _ hcbor

Most of what follows teaches Level 2. Level 1 you already understand -- it's just native_decide. Level 3 (typeclass obligations) uses a mix of both -- see Section 20.


Section 04

Tactics That Solve Things Automatically

These tactics try to close the goal entirely. Use them first. Only go manual if they fail.

native_decide / decide

Evaluate a concrete computation:

Leannative_decide / decide
theorem ex1 : 3 < 10 := by decide
theorem ex2 : "hello".length = 5 := by native_decide
theorem ex3 : mySchema.permits "Any" "Post" .R = true := by native_decide

Only works when all values are known and types have DecidableEq.

simp

Apply simplification rules:

Leansimp
theorem ex1 (n : Nat) : n + 0 = n := by simp
theorem ex2 (n : Nat) : 0 + n = n := by simp
theorem ex3 : [1, 2, 3].length = 3 := by simp

simp_all is more aggressive -- also simplifies hypotheses.

omega

Solve arithmetic (Nat and Int):

Leanomega
theorem ex1 (n : Nat) : n + 1 > n := by omega
theorem ex2 (a b : Nat) : a + b = b + a := by omega
theorem ex3 (n : Nat) (h : n > 5) : n > 3 := by omega

Notice: omega sees (h : n > 5) in the hypotheses automatically. No need to name it with intro first -- omega scans all available info.

rfl

Both sides are definitionally the same:

Leanrfl
theorem ex : 42 = 42 := by rfl

When automatic tactics fail

If native_decide, simp, omega, and rfl all fail, you need manual tactics. That's the rest of this guide.


Section 05

intro -- Open the Box

When the goal starts with (for all) or (implies), intro names those things so you can use them:

Leanintro
theorem ex : ∀ (a b : ByteArray), sha256 a = sha256 b → a = b := by
  intro a b hab

Before intro:

BEFORE
⊢ ∀ (a b : ByteArray), sha256 a = sha256 b → a = b

After intro:

AFTER
a : ByteArray
b : ByteArray
hab : sha256 a = sha256 b
⊢ a = b

intro unpacked the and into named hypotheses. Now you can use a, b, and hab in subsequent steps.

When to use intro: only when you need to reference hypotheses by name in later steps. If omega or simp solves the whole thing, skip intro:

LeanWhen to skip intro
-- omega handles intro internally, so just:
theorem ex (n : Nat) (h : n > 5) : n > 3 := by omega

-- But here you need hab by name for manual steps:
theorem ex : ∀ (a b : List CborField), H a = H b → a = b := by
  intro a b hab          -- need hab for the next steps
  unfold H at hab        -- use hab here
  ...

Section 06

unfold -- Expand a Definition

Replace a function name with its body:

Leanunfold in goal
def double (n : Nat) := n + n

theorem ex : double 5 = 10 := by
  unfold double
  -- goal changes from: double 5 = 10
  -- to: 5 + 5 = 10
  decide

unfold f at h expands inside a hypothesis:

Leanunfold at hypothesis
theorem ex (hab : H a = H b) : ... := by
  unfold H at hab
  -- hab changes from: H a = H b
  -- to: sha256 (cborEncode a) = sha256 (cborEncode b)

Section 07

have -- Add an Intermediate Fact

"From what I know, I can also conclude this":

Leanhave -- intermediate facts
theorem ex : ∀ (a b : List CborField), H a = H b → a = b := by
  intro a b hab
  unfold H at hab
  -- hab : sha256 (cborEncode a) = sha256 (cborEncode b)
  have hcbor := sha256_injective _ _ hab
  -- NEW: hcbor : cborEncode a = cborEncode b
  exact cborEncode_injective _ _ hcbor

have hcbor := sha256_injective _ _ hab applies the axiom sha256_injective to hab and names the result hcbor.

The _ means "infer this argument." Lean figures out what a and b are from context.

have can also use tactics:

Leanhave with tactic proof
have proof : b ≠ 0 := by omega    -- prove b ≠ 0 using omega

Section 08

exact -- Done

"This is the proof. Goal closed."

Leanexact
theorem ex (h : 1 = 1) : 1 = 1 := by
  exact h

exact takes a proof term and checks if it matches the goal exactly. If yes, done.


Section 09

rw -- Rewrite

Replace one thing with another using an equation:

Leanrw -- rewrite
theorem ex (a b : Nat) (h : a = b) : a + 1 = b + 1 := by
  rw [h]
  -- h says a = b
  -- replaces a with b in the goal
  -- goal becomes: b + 1 = b + 1
  -- Lean closes this automatically

rw [← h] rewrites backwards (replaces b with a).

rw [h] at h2 rewrites inside a hypothesis instead of the goal.


Section 10

cases -- Split by Constructor

For inductive types, check every possible case:

Leancases
theorem bool_em (b : Bool) : b = true ∨ b = false := by
  cases b
  · right; rfl     -- case b = false: choose right side
  · left; rfl      -- case b = true: choose left side

· focuses on one sub-goal. cases creates one sub-goal per constructor.

For exhaustive checking of all combinations:

LeanExhaustive cases
theorem prefix_inj : ∀ (p q : Prefix), p.toByte = q.toByte → p = q := by
  intro p q h
  cases p <;> cases q <;> simp_all [Prefix.toByte]

<;> applies the next tactic to ALL sub-goals. cases p <;> cases q creates 7x7=49 combinations, then simp_all solves each.


Section 11

constructor -- Split And

A ∧ B means "prove both A and B." constructor splits it:

Leanconstructor
theorem ex : 1 < 23 < 4 := by
  constructor          -- two goals: (1) 1 < 2 and (2) 3 < 4
  · decide             -- solve goal 1
  · decide             -- solve goal 2

Shorthand -- apply tactic to all goals:

Leanconstructor shorthand
theorem ex : 1 < 23 < 4 := by
  constructor <;> decide

For 3+ parts, use refine:

Leanrefine for multiple conjuncts
theorem ex : 1 = 12 = 23 = 3 := by
  refine ⟨?_, ?_, ?_⟩ <;> rfl

⟨?_, ?_, ?_⟩ creates one hole per conjunct. <;> rfl fills them all. This is the most common pattern in the ENC spec:

LeanENC spec pattern
theorem five_things : P1 ∧ P2 ∧ P3 ∧ P4 ∧ P5 := by
  refine ⟨?_, ?_, ?_, ?_, ?_⟩ <;> native_decide

Section 12

left / right -- Choose Or

A ∨ B means "prove either A or B." Pick a side:

Leanleft / right
theorem ex : 1 = 11 = 2 := by
  left       -- "I'll prove the left side"
  rfl

Section 13

use -- Prove Exists

∃ n, P n means "there exists an n where P holds." Provide the witness:

Leanuse -- existential witness
theorem ex : ∃ n : Nat, n > 5 := by
  use 6        -- "6 is such a value"
  decide       -- prove 6 > 5

Section 14

Axioms

Declare a fact without proof:

LeanAxiom declarations
axiom sha256 (input : ByteArray) : Hash
axiom sha256_injective : ∀ (a b : ByteArray), sha256 a = sha256 b → a = b

Use for things you can't prove (crypto assumptions). Everything built on axioms must be noncomputable.

Safety rule: only axiomatize what is known to be true. State less, not more.

#print axioms myTheorem shows which axioms a theorem depends on.


Section 15

The Full Chain -- H_injective

The most important proof in the spec. Read with Infoview open:

LeanAxioms and definition
axiom sha256_injective :
  ∀ (a b : ByteArray), sha256 a = sha256 b → a = b

axiom cborEncode_injective :
  ∀ (a b : List CborField), cborEncode a = cborEncode b → a = b

noncomputable def H (fields : List CborField) : Hash :=
  sha256 (cborEncode fields)

The proof:

LeanH_injective proof
theorem H_injective :
    ∀ (a b : List CborField), H a = H b → a = b := by

Line 1: intro a b hab

BEFORE
⊢ ∀ (a b), H a = H b → a = b
AFTER
a : List CborField
b : List CborField
hab : H a = H b
⊢ a = b

Unpacked and . Named everything.

Line 2: unfold H at hab

BEFORE
hab : H a = H b
⊢ a = b
AFTER
hab : sha256 (cborEncode a) = sha256 (cborEncode b)
⊢ a = b

Replaced H with its definition. Now we can see the sha256 and cborEncode.

Line 3: have hcbor := sha256_injective _ _ hab

BEFORE
hab : sha256(cbor a)
    = sha256(cbor b)
⊢ a = b
AFTER
hab : sha256(cbor a) = sha256(cbor b)
hcbor : cborEncode a = cborEncode b
⊢ a = b

Applied the sha256 axiom: "if sha256 outputs are equal, inputs are equal." Created new fact hcbor.

Line 4: exact cborEncode_injective _ _ hcbor

BEFORE
hcbor : cborEncode a
      = cborEncode b
⊢ a = b
AFTER
(no goals -- proof complete)

Applied the cbor axiom: "if cbor outputs are equal, inputs are equal." Goal a = b matches exactly. Done.

The whole chain

H(a) = H(b) sha256(cbor(a)) = sha256(cbor(b)) cbor(a) = cbor(b) a = b
[given: hab] → [unfold H] → [sha256_injective] → [cborEncode_injective]

Section 16

Proof as Transformation

Every proof is a path from hypotheses to goal. Tactics are the steps:

What you have — tactic —→ What you have (transformed)

Like group theory. You compose transformations until you reach the target. If no path exists, the proposition might be false -- or you're missing an axiom.

The compiler doesn't guess the path. You build it explicitly, one tactic at a time.


Section 17

Converting Between Propositions

Sometimes you have a proof of A but need a proof of B, where A and B are related but not identical:

Leanomega bridges arithmetic propositions
-- You have: h : b > 0
-- You need: b ≠ 0
-- They're related but different propositions
have proof : b ≠ 0 := by omega

omega is smart enough to bridge arithmetic propositions. For non-arithmetic, use other tactics:

LeanBridging propositions
-- rw bridges with equations
-- have: h : a = b
-- need: f a = f b
rw [h]

-- exact bridges with direct match
-- have: h : A ∧ B
-- need: A
exact h.1        -- .1 extracts the first part of ∧

-- need: B
exact h.2        -- .2 extracts the second part
Key insight: The compiler never assumes two propositions are "obviously" the same. You always provide the explicit transformation.

Section 18

How to Read Logical Symbols

SymbolRead asExampleMeaning
"and"A ∧ Bboth A and B are true
"or"A ∨ Bat least one is true
"implies"A → Bif A then B
"for all"∀ n, P nP holds for every n
"exists"∃ n, P nP holds for some n
¬"not"¬AA is false
"iff"A ↔ BA implies B and B implies A

How to prove each

GoalTacticWhat it does
A ∧ Bconstructorsplit into two goals: prove A, prove B
A ∨ Bleft or rightpick which side to prove
A → Bintro hassume A (name it h), prove B
∀ x, P xintro xpick arbitrary x, prove P x
∃ x, P xuse valprovide the specific value
¬Aintro hassume A, derive contradiction
A ↔ Bconstructorprove A→B and B→A separately

How to use each (when you have it as hypothesis)

HypothesisHow to use
h : A ∧ Bh.1 gives A, h.2 gives B
h : A ∨ Bcases h splits into case A and case B
h : A → Bh a_proof gives a proof of B
h : ∀ x, P xh val gives proof of P val
h : ∃ x, P xobtain ⟨x, hx⟩ := h extracts x and proof

Section 19

sorry -- Skip For Now

Leansorry placeholder
theorem todo : something complicated := by sorry

Compiles with a warning. Zero sorry = everything is proven. The ENC spec has 1 sorry (a placeholder in Composition.lean for an advanced cross-app proof).


Section 20

Common Patterns in the ENC Spec

The spec has 142 theorems across 54 modules (56 build targets). Here are the patterns.

Pattern 1: Single property (~50 theorems)

LeanPattern 1 -- single native_decide
theorem anyone_reads :
    schema.permits "Any" "Post" .R = true := by native_decide

Pattern 2: Two properties

LeanPattern 2 -- constructor + native_decide
theorem owner_access :
    schema.permits "Owner" "Post" .C = true ∧
    schema.permits "Owner" "Post" .R = true := by
  constructor <;> native_decide

Pattern 3: Many properties

LeanPattern 3 -- refine + native_decide
theorem full_check :
    P1 ∧ P2 ∧ P3 ∧ P4 ∧ P5 := by
  refine ⟨?_, ?_, ?_, ?_, ?_⟩ <;> native_decide

Pattern 4: Injectivity chain (axiom-based)

LeanPattern 4 -- axiom chain
theorem f_injective : ∀ a b, f a = f b → a = b := by
  intro a b h
  unfold f at h
  have h2 := inner_injective _ _ h
  exact base_injective _ _ h2

Pattern 5: Exhaustive cases

LeanPattern 5 -- cases + simp_all
theorem all_cases : ∀ p q, p.toByte = q.toByte → p = q := by
  intro p q h
  cases p <;> cases q <;> simp_all [toByte]

Pattern 6: Typeclass obligation -- existential witness

Used in typeclass instances to prove "every event type is creatable by some role":

LeanPattern 6 -- rcases + existential
all_creatable := by
  intro et het
  simp [List.Mem] at het           -- normalize list membership
  rcases het with rfl | rfl | rfl  -- split into one case per event type
  · exact"Owner", by native_decide-- witness: Owner can create this
  · exact"Owner", by native_decide⟩
  · exact"Any", by native_decide

rcases destructures the membership proof into one case per list element. For each case, ⟨role, proof⟩ provides the witness (which role) and proves the schema permits it.

Pattern 7: Typeclass obligation -- unfold + rfl

Used when a typeclass field says "this function equals that function":

LeanPattern 7 -- cases + rfl
reduces_to_createCommit := by intro w; cases w <;> rfl

cases w splits the write inductive into constructors, then rfl confirms each case is definitionally equal to createCommit.

Pattern 8: Typeclass obligation -- arithmetic bound

Used for proving component counts, list lengths, etc.:

LeanPattern 8 -- omega
components_nonempty := by omega    -- proves componentCount > 0

Pattern 9: Meta-theorem -- derive from typeclass

The composition meta-theorem extracts proofs from typeclass obligations:

LeanPattern 9 -- composition meta-theorem
theorem composition_from_typeclass (A W K Idx Q S : Type)
    [inst : ENCApp A W K Idx Q S] :
    (∀ w id enc exp, inst.buildCommit w id enc exp = ...) ∧  -- C1
    (∀ k m, inst.decrypt k (inst.encrypt k m) = some m) ∧    -- C3
    (∀ et, et ∈ inst.eventTypes → ∃ role, ...) ∧             -- C4
    (inst.componentCount > 0) :=                               -- C6
  ⟨inst.reduces_to_createCommit, inst.roundtrip,
   inst.all_creatable, inst.components_nonempty⟩
Key insight: The typeclass obligations ARE the proofs. If an instance compiles, composition holds.

Reference

Tactic Cheat Sheet

Try these first (automatic)

TacticSolves
native_decideConcrete computations with known values
decideSame, but slower
simpSimplification (n + 0 = n, etc.)
omegaArithmetic (n > 5 → n > 3, etc.)
rflBoth sides identical

Use these for manual proofs

TacticWhat it does
intro x hName variables and assumptions
unfold f at hExpand definition
have h := ...Add intermediate fact
exact eProvide final proof
rw [h]Rewrite using equation
cases xSplit into constructors
constructorSplit into two goals
left / rightChoose side of
<;> tacticApply to all sub-goals
refine ⟨?_, ?_⟩Create multiple holes
use valProvide witness
apply fMatch goal with lemma
rcases h with rfl | rflDestructure into cases (flexible)
simp [f] at hSimplify using lemma f at hypothesis
contradictionHypotheses conflict
sorrySkip (warning)