diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8836ac9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + paths-ignore: + - 'README.md' + - 'CLAUDE.md' + - 'agent.md' + push: + branches: + - main + paths-ignore: + - 'README.md' + - 'CLAUDE.md' + - 'agent.md' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + NEXT_TELEMETRY_DISABLED: 1 + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Restore Next.js cache + uses: actions/cache@v5 + with: + path: .next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock', 'package.json') }}-${{ hashFiles('pages/**/*', 'components/**/*', 'lib/**/*', 'data/**/*', 'styles/**/*', 'content/**/*', 'public/**/*', 'next.config.js', 'postcss.config.js', 'tailwind.config.js') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock', 'package.json') }}- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build site + run: bun run build diff --git a/components/research/Hypothesis.jsx b/components/research/Hypothesis.jsx new file mode 100644 index 0000000..cc573e1 --- /dev/null +++ b/components/research/Hypothesis.jsx @@ -0,0 +1,39 @@ +export default function Hypothesis({ + name, + constraint, + source, + children, + border = true +}) { + return ( +
  • +
    + + + + + {name} + {constraint} + +
    +

    + + {source} + +

    +

    {children}

    +
    +
    +
  • + ) +} diff --git a/components/research/SafeGuarantee.jsx b/components/research/SafeGuarantee.jsx new file mode 100644 index 0000000..b15468b --- /dev/null +++ b/components/research/SafeGuarantee.jsx @@ -0,0 +1,89 @@ +import { useState, useEffect, useRef } from 'react' + +const FORMAL_INVARIANTS = [ + '\u2200 key, next(key) \u2260 0 \u2192 reachable(SENTINEL, key)', + '\u2200 key \u2260 0, next(key) \u2260 0 \u2194 reachable(SENTINEL, key)', + '\u2200 a b, reachable(a, b) \u2227 reachable(b, a) \u2192 a = b' +] + +export default function SafeGuarantee() { + const [showEnglish, setShowEnglish] = useState(true) + const timerRef = useRef(null) + + useEffect(() => { + timerRef.current = setTimeout(() => setShowEnglish(false), 5000) + return () => clearTimeout(timerRef.current) + }, []) + + const handleToggle = () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + setShowEnglish((prev) => !prev) + } + + return ( +
    + + +
    +
    + {FORMAL_INVARIANTS.map((formal, i) => ( + + {formal} + + ))} +
    +
    +

    + The owners mapping forms a proper loop-free linked list starting at + a special node called{' '} + + SENTINEL + + , and the owners are exactly the addresses on that list. +

    +
    +
    +
    + ) +} diff --git a/data/research.js b/data/research.js index e793009..106699b 100644 --- a/data/research.js +++ b/data/research.js @@ -1,4 +1,14 @@ export const research = [ + { + slug: 'safe-owner-reachability', + title: 'Safe Owner List Invariants', + subtitle: + 'Formally verified linked list invariants of the Safe smart account.', + description: + 'How we proved reachability and acyclicity across all four ownership-mutating functions in the Safe OwnerManager using Verity and Lean 4.', + date: '2026-04-09', + tag: 'Case study' + }, { slug: 'formalizing-transaction-interpretation', title: 'Formalizing Transaction Interpretation', @@ -29,15 +39,6 @@ export const research = [ date: '2025-07-01', tag: 'Publication' }, - { - slug: 'what-is-a-formal-proof', - title: 'What is a formal proof?', - subtitle: 'A short explanation for non-specialists.', - description: - 'An introduction to formal verification for smart contracts — what it means to prove a contract correct.', - date: '2025-06-01', - tag: 'Explainer' - }, { slug: 'lido-vault-solvency', title: 'Lido V3 Vault Solvency Guarantee', @@ -47,5 +48,14 @@ export const research = [ 'How we proved the solvency invariant of Lido V3 StakingVaults using Verity and Lean 4.', date: '2025-06-15', tag: 'Case study' + }, + { + slug: 'what-is-a-formal-proof', + title: 'What is a formal proof?', + subtitle: 'A short explanation for non-specialists.', + description: + 'An introduction to formal verification for smart contracts — what it means to prove a contract correct.', + date: '2025-06-01', + tag: 'Explainer' } ] diff --git a/lib/getSortedResearch.js b/lib/getSortedResearch.js new file mode 100644 index 0000000..c0ca933 --- /dev/null +++ b/lib/getSortedResearch.js @@ -0,0 +1,5 @@ +import { research } from '../data/research' + +export function getSortedResearch() { + return [...research].sort((a, b) => b.date.localeCompare(a.date)) +} diff --git a/pages/research/formalizing-transaction-interpretation.jsx b/pages/research/formalizing-transaction-interpretation.jsx index 14ee33b..9d7debb 100644 --- a/pages/research/formalizing-transaction-interpretation.jsx +++ b/pages/research/formalizing-transaction-interpretation.jsx @@ -6,7 +6,7 @@ import CodeBlock from '../../components/research/CodeBlock' import Disclosure from '../../components/research/Disclosure' import ExternalLink from '../../components/research/ExternalLink' import HighlightedDSL from '../../components/research/HighlightedDSL' -import { research } from '../../data/research' +import { getSortedResearch } from '../../lib/getSortedResearch' export default function WhyClearSigningShouldNotRequireTrustPage() { const canonicalUrl = @@ -36,7 +36,7 @@ export default function WhyClearSigningShouldNotRequireTrustPage() { }, mainEntityOfPage: canonicalUrl } - const otherResearch = research.filter( + const otherResearch = getSortedResearch().filter( (r) => r.slug !== 'formalizing-transaction-interpretation' ) diff --git a/pages/research/index.jsx b/pages/research/index.jsx index 82f6e28..2afee6f 100644 --- a/pages/research/index.jsx +++ b/pages/research/index.jsx @@ -2,9 +2,11 @@ import Head from 'next/head' import PageLayout from '../../components/PageLayout' import ResearchCard from '../../components/ResearchCard' import SectionHeader from '../../components/ui/SectionHeader' -import { research } from '../../data/research' +import { getSortedResearch } from '../../lib/getSortedResearch' export default function ResearchPage() { + const research = getSortedResearch() + return ( <> diff --git a/pages/research/lido-vault-solvency.jsx b/pages/research/lido-vault-solvency.jsx index d760266..2990cb0 100644 --- a/pages/research/lido-vault-solvency.jsx +++ b/pages/research/lido-vault-solvency.jsx @@ -6,48 +6,15 @@ import Guarantee from '../../components/research/Guarantee' import Disclosure from '../../components/research/Disclosure' import CodeBlock from '../../components/research/CodeBlock' import ExternalLink from '../../components/research/ExternalLink' -import { research } from '../../data/research' - -function Hypothesis({ name, constraint, source, children, border = true }) { - return ( -
  • -
    - - - - - {name} - {constraint} - -
    -

    - - {source} - -

    -

    {children}

    -
    -
    -
  • - ) -} +import Hypothesis from '../../components/research/Hypothesis' +import { getSortedResearch } from '../../lib/getSortedResearch' const VERIFY_COMMAND = `git clone https://github.com/lfglabs-dev/verity-benchmark cd verity-benchmark ./scripts/run_default_agent.sh lido/vaulthub_locked/locked_funds_solvency` export default function LidoVaultSolvencyPage() { - const otherResearch = research.filter( + const otherResearch = getSortedResearch().filter( (r) => r.slug !== 'lido-vault-solvency' ) diff --git a/pages/research/safe-owner-reachability.jsx b/pages/research/safe-owner-reachability.jsx new file mode 100644 index 0000000..ff6cd37 --- /dev/null +++ b/pages/research/safe-owner-reachability.jsx @@ -0,0 +1,429 @@ +import Head from 'next/head' +import Link from 'next/link' +import PageLayout from '../../components/PageLayout' +import ResearchCard from '../../components/ResearchCard' +import SafeGuarantee from '../../components/research/SafeGuarantee' +import Disclosure from '../../components/research/Disclosure' +import CodeBlock from '../../components/research/CodeBlock' +import ExternalLink from '../../components/research/ExternalLink' +import Hypothesis from '../../components/research/Hypothesis' +import { getSortedResearch } from '../../lib/getSortedResearch' + +const VERIFY_COMMAND = `git clone https://github.com/lfglabs-dev/verity-benchmark +cd verity-benchmark +lake build Benchmark.Cases.Safe.OwnerManagerReach.Compile` + +const OWNER_MANAGER_SOL = + 'https://github.com/safe-fndn/safe-smart-account/blob/a2e19c6aa42a45ceec68057f3fa387f169c5b321/contracts/base/OwnerManager.sol' + +const VERITY_OWNER_MANAGER_CONTRACT = + 'https://github.com/lfglabs-dev/verity-benchmark/blob/main/Benchmark/Cases/Safe/OwnerManagerReach/Contract.lean' + +export default function SafeOwnerReachabilityPage() { + const otherResearch = getSortedResearch().filter( + (r) => r.slug !== 'safe-owner-reachability' + ) + + return ( + <> + + + Safe Owner List Invariants | Research | LFG Labs + + + + +
    + + +
    +

    + Safe Owner List Invariants +

    +
    + + {/* The Guarantee */} +
    + +

    + The{' '} + + Safe smart account + {' '} + manages its owners using a singly-linked list stored in a + mapping{' '} + + owners: address → address + + . A sentinel node at address{' '} + 0x1 anchors + the list. All four ownership-mutating functions are modeled: +

    +
    +              {'setupOwners  \u2192 build initial list\n'}
    +              {'addOwner     \u2192 insert at head\n'}
    +              {'removeOwner  \u2192 unlink node\n'}
    +              {'swapOwner    \u2192 atomic replacement'}
    +            
    +
    + + {/* Why this matters */} +
    +

    + Why this matters +

    +

    + The Safe is the most widely used multi-signature wallet on + Ethereum, securing billions of dollars. If the owners list in{' '} + + OwnerManager + {' '} + were malformed, approval checks could skip a real owner or treat + the signer set differently than what is expected. +

    + +

    + The owners mapping forms a proper loop-free linked list + starting at a special node called{' '} + SENTINEL, and + the owners are exactly the addresses on that list. +

    +

    + In Lean, that goal is split into four named properties: +

    +
      +
    • + inListReachable + : every node with a non-zero successor is reachable from + SENTINEL +
    • +
    • + ownerListInvariant + : membership (non-zero successor) is equivalent to + reachability from SENTINEL (combines{' '} + inListReachable and{' '} + reachableInList) +
    • +
    • + acyclic: the + linked list has no SENTINEL cycles +
    • +
    • + stronglyAcyclic + : reachability is antisymmetric (no cycles at all) +
    • +
    +

    + These correspond to invariants from Certora's{' '} + + OwnerReach.spec + + . Threshold management is elided as it does not affect the + owners mapping. +

    +
    +
    + + {/* How this was proven */} +
    +

    + How this was proven +

    +

    + The ownership operations are modeled in Verity from the Solidity{' '} + OwnerManager.sol{' '} + implementation in safe-smart-account; the Verity contract slice is + in{' '} + + Contract.lean + + . Each function mutates the linked list differently: +

    +
      +
    • + setupOwners: constructs the initial linked + list from a list of addresses (base case) +
    • +
    • + addOwner: head insertion. The new owner is + placed between SENTINEL and the old head +
    • +
    • + removeOwner: chain excision. The previous + owner's pointer is redirected past the removed node +
    • +
    • + swapOwner: atomic replacement. The old owner + is unlinked and the new owner spliced into its position +
    • +
    +

    + Reachability is expressed using witness chains: a + concrete list of addresses where each consecutive pair follows + the{' '} + owners{' '} + mapping. This turns the transitive closure into induction on + list indices, making the proof mechanically checkable. +

    +

    + For{' '} + removeOwner{' '} + and{' '} + swapOwner, + the proofs require strong acyclicity{' '} + (antisymmetry of the reachability relation) to show that + excising or replacing a node does not orphan downstream + nodes. This matches Certora's{' '} + reach_invariant{' '} + axiom. +

    +

    + Reference proofs are provided in{' '} + + Proofs.lean + + . The proof is checked by Lean 4's kernel, a small + program that accepts or rejects proofs deterministically. If + the proof were wrong, it would not compile. +

    + + {VERIFY_COMMAND} +

    + If the build succeeds, the proofs are correct.{' '} + + Source repository + +

    +
    +
    + + {/* Proof status */} +
    +

    + Proof status +

    +

    + 9 of 12 theorems are proven. The remaining three{' '} + ownerListInvariant{' '} + preservation theorems remain open in{' '} + + OpenProofs.lean + + . +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FunctioninListReachableownerListInvariantacyclicity
    setupOwnersprovenprovenproven
    addOwnerprovenopenproven
    removeOwnerprovenopenproven
    swapOwnerprovenopenproven
    +
    +
    + + {/* Hypotheses */} +
    +

    + Hypotheses +

    +

    + The proofs use zero axioms. Each theorem requires hypotheses + drawn from the function's preconditions and structural + properties of the linked list. The hypotheses vary by function: +

    +
      + + The owner cannot be the zero address or the sentinel. These + are enforced by{' '} + require guards + in every ownership-mutating function. + + + The owner must not already be in the list. A zero mapping + value means the address is not yet a node. Used by{' '} + addOwner and{' '} + swapOwner. + + + The predecessor must correctly point to the target owner. + Used by{' '} + removeOwner{' '} + and{' '} + swapOwner to + verify the caller supplied the right predecessor. + + + The proof is inductive: it assumes the invariant holds before + the function executes and shows it holds after.{' '} + setupOwners{' '} + is the base case and does not require this hypothesis. + + + The linked list does not cycle back to SENTINEL before + reaching its natural end. Proven as a theorem for{' '} + setupOwners{' '} + and{' '} + addOwner. + Used as a hypothesis for{' '} + removeOwner{' '} + and{' '} + swapOwner. + + + Strong acyclicity: the reachability relation is + antisymmetric. Required by{' '} + removeOwner{' '} + and{' '} + swapOwner{' '} + to ensure that excising or replacing a node doesn't + orphan downstream nodes through non-SENTINEL cycles. + + + The owner being removed must actually be in the list (has a + non-zero successor). Removing a node that isn't in the + list would zero SENTINEL's pointer. Used by{' '} + removeOwner. + + + In{' '} + swapOwner, + the old owner cannot be its own predecessor. A self-loop + would cause the if-chain in the storage map to zero out the + previous owner's pointer, orphaning the new owner. + +
    +

    + setupOwners{' '} + additionally requires a{' '} + hClean{' '} + hypothesis (all storage slots are zero) to ensure no stale + mappings from a prior state. +

    +

    + + View specs in Lean + + + View proofs in Lean + + + View open theorems + +

    +
    + + {/* Learn more */} +
    +

    + Learn more +

    +

    + + Certora OwnerReach.spec + + {', the original specification this work is based on.'} +

    +

    + + What is a formal proof? + {' '} + A short explanation for non-specialists. +

    +
    + + {otherResearch.length > 0 && ( +
    +

    + More research +

    +
    + {otherResearch.map((r) => ( + + ))} +
    +
    + )} +
    +
    + + ) +} diff --git a/pages/research/verity-benchmark.jsx b/pages/research/verity-benchmark.jsx index b539f66..7b86169 100644 --- a/pages/research/verity-benchmark.jsx +++ b/pages/research/verity-benchmark.jsx @@ -3,10 +3,12 @@ import Link from 'next/link' import PageLayout from '../../components/PageLayout' import ResearchCard from '../../components/ResearchCard' import ExternalLink from '../../components/research/ExternalLink' -import { research } from '../../data/research' +import { getSortedResearch } from '../../lib/getSortedResearch' export default function VerityBenchmarkPage() { - const otherResearch = research.filter((r) => r.slug !== 'verity-benchmark') + const otherResearch = getSortedResearch().filter( + (r) => r.slug !== 'verity-benchmark' + ) return ( <> diff --git a/pages/research/verity.jsx b/pages/research/verity.jsx index fab151b..c6e2a40 100644 --- a/pages/research/verity.jsx +++ b/pages/research/verity.jsx @@ -3,10 +3,10 @@ import Link from 'next/link' import PageLayout from '../../components/PageLayout' import ResearchCard from '../../components/ResearchCard' import ExternalLink from '../../components/research/ExternalLink' -import { research } from '../../data/research' +import { getSortedResearch } from '../../lib/getSortedResearch' export default function VerityPage() { - const otherResearch = research.filter((r) => r.slug !== 'verity') + const otherResearch = getSortedResearch().filter((r) => r.slug !== 'verity') return ( <> diff --git a/pages/research/what-is-a-formal-proof.jsx b/pages/research/what-is-a-formal-proof.jsx index 1483546..1056dd0 100644 --- a/pages/research/what-is-a-formal-proof.jsx +++ b/pages/research/what-is-a-formal-proof.jsx @@ -2,10 +2,10 @@ import Head from 'next/head' import Link from 'next/link' import PageLayout from '../../components/PageLayout' import ResearchCard from '../../components/ResearchCard' -import { research } from '../../data/research' +import { getSortedResearch } from '../../lib/getSortedResearch' export default function WhatIsAFormalProofPage() { - const otherResearch = research.filter( + const otherResearch = getSortedResearch().filter( (r) => r.slug !== 'what-is-a-formal-proof' )