From c9e82f1bf1c44bdf7ce2c6e1d09aec49be392619 Mon Sep 17 00:00:00 2001 From: Marelize Date: Tue, 28 Jul 2026 13:24:27 +0200 Subject: [PATCH 01/31] fix(prover): make WriterstoEqual actually compare both encodings Signed-off-by: Marelize Signed-off-by: Coenie Beyers --- prover/utils/utils.go | 17 ++++++++----- prover/utils/utils_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/prover/utils/utils.go b/prover/utils/utils.go index c4d37238021..292fcb36057 100644 --- a/prover/utils/utils.go +++ b/prover/utils/utils.go @@ -271,17 +271,22 @@ func FillRange[T constraints.Integer](dst []T, start T) { } } +// WriterstoEqual reports whether two io.WriterTo produce identical encodings. +// +// Each operand gets its own buffer. Serializing both into one buffer does not +// work: bytes.Buffer.Bytes() aliases the buffer's array and Reset() keeps that +// array, so the second WriteTo overwrites the first operand's bytes in place +// and the comparison ends up matching a slice against itself, reporting +// equality for any two encodings of the same length. func WriterstoEqual(expected, actual io.WriterTo) error { - var bb bytes.Buffer - if _, err := expected.WriteTo(&bb); err != nil { + var expectedBuf, actualBuf bytes.Buffer + if _, err := expected.WriteTo(&expectedBuf); err != nil { return err } - ab := bb.Bytes() - bb.Reset() - if _, err := actual.WriteTo(&bb); err != nil { + if _, err := actual.WriteTo(&actualBuf); err != nil { return err } - return BytesEqual(ab, bb.Bytes()) + return BytesEqual(expectedBuf.Bytes(), actualBuf.Bytes()) } // BytesEqual between byte slices a,b diff --git a/prover/utils/utils_test.go b/prover/utils/utils_test.go index 590be615e13..7013214275a 100644 --- a/prover/utils/utils_test.go +++ b/prover/utils/utils_test.go @@ -1,7 +1,9 @@ package utils_test import ( + "errors" "fmt" + "io" "testing" "github.com/consensys/linea-monorepo/prover/utils" @@ -75,3 +77,53 @@ func TestNextPowerOfTwoExample(t *testing.T) { }) } } + +// constWriterTo writes a fixed payload, so a test can control the exact bytes +// each operand of WriterstoEqual produces. +type constWriterTo []byte + +func (c constWriterTo) WriteTo(w io.Writer) (int64, error) { + n, err := w.Write(c) + return int64(n), err +} + +func TestWriterstoEqual(t *testing.T) { + for _, tc := range []struct { + name string + expected []byte + actual []byte + equal bool + }{ + {"identical", []byte("abcd"), []byte("abcd"), true}, + // the regression: same length, different content. Serializing both + // operands into one reused buffer compared a slice with itself and + // reported these equal. + {"same length, different content", []byte("abcd"), []byte("wxyz"), false}, + {"differs in last byte only", []byte("abcd"), []byte("abcz"), false}, + {"shorter actual", []byte("abcd"), []byte("ab"), false}, + {"longer actual", []byte("ab"), []byte("abcd"), false}, + {"both empty", []byte{}, []byte{}, true}, + {"empty vs non-empty", []byte{}, []byte("a"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := utils.WriterstoEqual(constWriterTo(tc.expected), constWriterTo(tc.actual)) + if tc.equal { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestWriterstoEqualPropagatesWriteErrors(t *testing.T) { + boom := errors.New("boom") + require.ErrorIs(t, utils.WriterstoEqual(failingWriterTo{boom}, constWriterTo("a")), boom) + require.ErrorIs(t, utils.WriterstoEqual(constWriterTo("a"), failingWriterTo{boom}), boom) +} + +// failingWriterTo always fails, to check the error is returned rather than +// swallowed into a false "not equal". +type failingWriterTo struct{ err error } + +func (f failingWriterTo) WriteTo(io.Writer) (int64, error) { return 0, f.err } From 0a1a416bbdb6a006b600247b7a1d582c9c5d8aa2 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Thu, 6 Aug 2026 10:17:02 +0200 Subject: [PATCH 02/31] chore(prover): reword the WriterstoEqual doc and name its regression test Signed-off-by: Coenie Beyers --- prover/utils/utils.go | 13 +++++++------ prover/utils/utils_test.go | 7 +++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/prover/utils/utils.go b/prover/utils/utils.go index 292fcb36057..b75912440ca 100644 --- a/prover/utils/utils.go +++ b/prover/utils/utils.go @@ -271,13 +271,14 @@ func FillRange[T constraints.Integer](dst []T, start T) { } } -// WriterstoEqual reports whether two io.WriterTo produce identical encodings. +// WriterstoEqual returns nil if the two io.WriterTo produce identical +// encodings; otherwise an error describing the difference, or the first +// WriteTo failure. // -// Each operand gets its own buffer. Serializing both into one buffer does not -// work: bytes.Buffer.Bytes() aliases the buffer's array and Reset() keeps that -// array, so the second WriteTo overwrites the first operand's bytes in place -// and the comparison ends up matching a slice against itself, reporting -// equality for any two encodings of the same length. +// Each operand must get its own buffer: bytes.Buffer.Bytes() aliases the +// buffer's array across Reset(), so serializing both into one reused buffer +// compares a slice against itself and reports equality for any two encodings +// of the same length. func WriterstoEqual(expected, actual io.WriterTo) error { var expectedBuf, actualBuf bytes.Buffer if _, err := expected.WriteTo(&expectedBuf); err != nil { diff --git a/prover/utils/utils_test.go b/prover/utils/utils_test.go index 7013214275a..c66a83cf49c 100644 --- a/prover/utils/utils_test.go +++ b/prover/utils/utils_test.go @@ -116,6 +116,13 @@ func TestWriterstoEqual(t *testing.T) { } } +// TestWriterstoEqual_Regression_SameLengthDifferentContent pins the aliasing +// bug where both operands were serialized into one reused bytes.Buffer, so +// any two encodings of the same length compared equal. +func TestWriterstoEqual_Regression_SameLengthDifferentContent(t *testing.T) { + require.Error(t, utils.WriterstoEqual(constWriterTo("abcd"), constWriterTo("wxyz"))) +} + func TestWriterstoEqualPropagatesWriteErrors(t *testing.T) { boom := errors.New("boom") require.ErrorIs(t, utils.WriterstoEqual(failingWriterTo{boom}, constWriterTo("a")), boom) From 786bd6fd898fe3b66e6b9255ea81a0d0f7a6e665 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Fri, 24 Jul 2026 15:41:31 +0200 Subject: [PATCH 03/31] fix(prover): carry the verifying key through toLagrange Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 590d937f0c5..a98d949a33a 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -156,17 +156,19 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst func toLagrange(srs kzg.SRS, sizeLagrange int) (kzg.SRS, error) { var err error + // the verifying key is basis-independent: carry it over so the derived SRS + // (and any dump written from it) is faithful, not just its proving key switch srs := srs.(type) { case *kzg254.SRS: - lagrange := &kzg254.SRS{} + lagrange := &kzg254.SRS{Vk: srs.Vk} lagrange.Pk.G1, err = kzg254.ToLagrangeG1(srs.Pk.G1[:sizeLagrange]) return lagrange, err case *kzg377.SRS: - lagrange := &kzg377.SRS{} + lagrange := &kzg377.SRS{Vk: srs.Vk} lagrange.Pk.G1, err = kzg377.ToLagrangeG1(srs.Pk.G1[:sizeLagrange]) return lagrange, err case *kzgbw6.SRS: - lagrange := &kzgbw6.SRS{} + lagrange := &kzgbw6.SRS{Vk: srs.Vk} lagrange.Pk.G1, err = kzgbw6.ToLagrangeG1(srs.Pk.G1[:sizeLagrange]) return lagrange, err default: From 7e8d2abcc4ad79bfef9ae5bd64d9a12e180613e2 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Fri, 24 Jul 2026 15:41:44 +0200 Subject: [PATCH 04/31] feat(prover): persist the derived Lagrange SRS so it isn't re-derived on every start Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 184 +++++++++++++++--- prover/circuits/srs_store_test.go | 300 ++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+), 22 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index a98d949a33a..9c6663e9897 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -10,6 +10,7 @@ import ( "regexp" "sort" "strconv" + "sync" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/kzg" @@ -23,15 +24,36 @@ import ( ) type SRSStore struct { + // mu guards entries; a file is safe to read unlocked because it is only + // registered after being atomically published, and published files are immutable. + mu sync.RWMutex entries map[ecc.ID][]fsEntry + rootDir string } type fsEntry struct { isCanonical bool size int path string + source string // the ceremony tag in the file name: aleo, aztec or celo } +// curveFileNames maps a curve ID to the token naming it in SRS file names. +var curveFileNames = map[ecc.ID]string{ + ecc.BLS12_377: "bls12377", + ecc.BN254: "bn254", + ecc.BW6_761: "bw6761", +} + +// curveIDsByFileName is the inverse of curveFileNames. +var curveIDsByFileName = func() map[string]ecc.ID { + m := make(map[string]ecc.ID, len(curveFileNames)) + for id, name := range curveFileNames { + m[name] = id + } + return m +}() + // NewSRSStore creates a new SRSStore func NewSRSStore(rootDir string) (*SRSStore, error) { // list all the files in rootDir @@ -45,6 +67,7 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { srsStore := &SRSStore{ entries: make(map[ecc.ID][]fsEntry), + rootDir: rootDir, } srsStore.entries[ecc.BLS12_377] = []fsEntry{} srsStore.entries[ecc.BN254] = []fsEntry{} @@ -68,15 +91,9 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { isCanonical := matches[2] == "canonical" size, _ := strconv.Atoi(matches[3]) - var curveID ecc.ID - switch matches[4] { - case "bls12377": - curveID = ecc.BLS12_377 - case "bn254": - curveID = ecc.BN254 - case "bw6761": - curveID = ecc.BW6_761 - default: + source := matches[5] + curveID, ok := curveIDsByFileName[matches[4]] + if !ok { return nil, errors.New("curve not supported") } @@ -84,6 +101,7 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { isCanonical: isCanonical, size: size, path: filepath.Join(rootDir, fileName), + source: source, }) } @@ -102,9 +120,12 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst sizeCanonical, sizeLagrange := plonk.SRSSize(ccs) curveID := fieldToCurve(ccs.Field()) + entries := store.entriesSnapshot(curveID) + // find the canonical srs var canonicalSRS kzg.SRS - for _, entry := range store.entries[curveID] { + var canonicalEntry fsEntry + for _, entry := range entries { if entry.isCanonical && entry.size >= sizeCanonical { canonicalSRS = kzg.NewSRS(curveID) data, err := os.ReadFile(entry.path) @@ -114,6 +135,7 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst if err := canonicalSRS.ReadDump(bytes.NewReader(data), sizeCanonical); err != nil { return nil, nil, err } + canonicalEntry = entry break } } @@ -124,18 +146,32 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst // find the lagrange srs var lagrangeSRS kzg.SRS - for _, entry := range store.entries[curveID] { - if !entry.isCanonical && entry.size == sizeLagrange { - lagrangeSRS = kzg.NewSRS(curveID) - data, err := os.ReadFile(entry.path) - if err != nil { - return nil, nil, err - } - if err := lagrangeSRS.ReadDump(bytes.NewReader(data)); err != nil { - return nil, nil, err - } - break + for _, entry := range entries { + if entry.isCanonical || entry.size != sizeLagrange { + continue } + if entry.source != canonicalEntry.source { + // a lagrange basis from a different ceremony is inconsistent with + // the canonical SRS; skip it rather than mix ceremonies + logrus.Debugf("skipping lagrange SRS %s: ceremony %q != canonical's %q", entry.path, entry.source, canonicalEntry.source) + continue + } + srs := kzg.NewSRS(curveID) + data, err := os.ReadFile(entry.path) + if err == nil { + err = srs.ReadDump(bytes.NewReader(data)) + } + if err == nil && pkG1Len(srs) != sizeLagrange { + err = fmt.Errorf("dump has %d points, want %d", pkG1Len(srs), sizeLagrange) + } + if err != nil { + // a lagrange dump is reconstructible (unlike the canonical SRS): log + // and fall back to deriving, which re-persists over this same path + logrus.Warnf("could not load lagrange SRS %s, re-deriving it: %v", entry.path, err) + continue + } + lagrangeSRS = srs + break } if lagrangeSRS == nil { @@ -143,17 +179,121 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst if sizeCanonical < sizeLagrange { panic("canonical SRS is smaller than lagrange SRS") } - logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d\n", sizeCanonical, sizeLagrange) + logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d", sizeCanonical, sizeLagrange) var err error lagrangeSRS, err = toLagrange(canonicalSRS, sizeLagrange) if err != nil { return nil, nil, err } + // Persist the derived Lagrange SRS so subsequent runs load it from disk + // instead of re-deriving it. Best-effort: a failed write must not fail + // the caller. + if err := store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID, canonicalEntry.source); err != nil { + logrus.Warnf("could not persist derived lagrange SRS (continuing): %v", err) + } } return canonicalSRS, lagrangeSRS, nil } +// entriesSnapshot returns a copy of the entries for curveID, safe to iterate unlocked. +func (store *SRSStore) entriesSnapshot(curveID ecc.ID) []fsEntry { + store.mu.RLock() + defer store.mu.RUnlock() + return append([]fsEntry(nil), store.entries[curveID]...) +} + +// register adds a lagrange entry to the index unless an equivalent one exists. +func (store *SRSStore) register(curveID ecc.ID, newEntry fsEntry) { + store.mu.Lock() + defer store.mu.Unlock() + for _, entry := range store.entries[curveID] { + if !entry.isCanonical && entry.size == newEntry.size && entry.source == newEntry.source { + return + } + } + store.entries[curveID] = append(store.entries[curveID], newEntry) + sort.Slice(store.entries[curveID], func(i, j int) bool { + return store.entries[curveID][i].size < store.entries[curveID][j].size + }) +} + +// cacheLagrange writes a derived Lagrange SRS into the store's directory using +// the naming scheme NewSRSStore parses, and registers it in the index. The dump +// is fsync'd and renamed into place atomically, so a torn write cannot appear +// under a trusted name; a bad dump is caught on the next load and re-derived. +func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID, source string) error { + curveName, ok := curveFileNames[curveID] + if !ok { + return fmt.Errorf("curve not supported: %s", curveID) + } + + fileName := fmt.Sprintf("kzg_srs_lagrange_%d_%s_%s.memdump", sizeLagrange, curveName, source) + finalPath := filepath.Join(store.rootDir, fileName) + + f, err := os.CreateTemp(store.rootDir, fileName+".tmp") + if err != nil { + return err + } + if err := lagrangeSRS.WriteDump(f); err != nil { + f.Close() + os.Remove(f.Name()) + return fmt.Errorf("writing srs dump: %w", err) + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(f.Name()) + return err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return err + } + // ceremony files in the store are world-readable; match them so the cache + // stays loadable when a later run uses a different uid + if err := os.Chmod(f.Name(), 0o644); err != nil { + os.Remove(f.Name()) + return err + } + if err := os.Rename(f.Name(), finalPath); err != nil { + os.Remove(f.Name()) + return err + } + syncDir(store.rootDir) + + store.register(curveID, fsEntry{isCanonical: false, size: sizeLagrange, path: finalPath, source: source}) + + logrus.Infof("persisted derived lagrange SRS to %s", finalPath) + return nil +} + +// pkG1Len returns the number of G1 proving-key points in the SRS, or -1. +func pkG1Len(srs kzg.SRS) int { + switch srs := srs.(type) { + case *kzg254.SRS: + return len(srs.Pk.G1) + case *kzg377.SRS: + return len(srs.Pk.G1) + case *kzgbw6.SRS: + return len(srs.Pk.G1) + default: + return -1 + } +} + +// syncDir best-effort fsyncs a directory so a just-renamed file survives a crash. +func syncDir(dir string) { + d, err := os.Open(dir) + if err != nil { + logrus.Warnf("could not open %s to sync it: %v", dir, err) + return + } + if err := d.Sync(); err != nil { + logrus.Warnf("could not sync %s: %v", dir, err) + } + d.Close() +} + func toLagrange(srs kzg.SRS, sizeLagrange int) (kzg.SRS, error) { var err error // the verifying key is basis-independent: carry it over so the derived SRS diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 53ee27ec109..43b8e307ef7 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -1,9 +1,26 @@ package circuits import ( + "bytes" + "context" + "fmt" + "math/big" + "os" + "path/filepath" + "sync" "testing" + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/kzg" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/frontend/cs/scs" + "github.com/consensys/gnark/test/unsafekzg" "github.com/stretchr/testify/require" + + kzg377 "github.com/consensys/gnark-crypto/ecc/bls12-377/kzg" + kzg254 "github.com/consensys/gnark-crypto/ecc/bn254/kzg" + kzgbw6 "github.com/consensys/gnark-crypto/ecc/bw6-761/kzg" ) func TestSRSStore(t *testing.T) { @@ -22,3 +39,286 @@ func TestSRSStore(t *testing.T) { } } } + +// assertSameVk fails the test unless both SRSs carry the same verifying key. +func assertSameVk(t *testing.T, want, got kzg.SRS) { + t.Helper() + switch want := want.(type) { + case *kzg254.SRS: + require.Equal(t, want.Vk, got.(*kzg254.SRS).Vk, "verifying key must survive derivation and persistence") + case *kzg377.SRS: + require.Equal(t, want.Vk, got.(*kzg377.SRS).Vk, "verifying key must survive derivation and persistence") + case *kzgbw6.SRS: + require.Equal(t, want.Vk, got.(*kzgbw6.SRS).Vk, "verifying key must survive derivation and persistence") + default: + t.Fatalf("unsupported SRS type %T", want) + } +} + +// newTestCanonicalSRS returns a small test-only canonical SRS for curveID. +func newTestCanonicalSRS(t *testing.T, curveID ecc.ID, size uint64) kzg.SRS { + t.Helper() + var ( + srs kzg.SRS + err error + ) + switch curveID { + case ecc.BN254: + srs, err = kzg254.NewSRS(size, big.NewInt(42)) + case ecc.BLS12_377: + srs, err = kzg377.NewSRS(size, big.NewInt(42)) + case ecc.BW6_761: + srs, err = kzgbw6.NewSRS(size, big.NewInt(42)) + default: + t.Fatalf("unsupported curve %s", curveID) + } + require.NoError(t, err) + return srs +} + +func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { + testCases := []struct { + curveID ecc.ID + curveName string + }{ + {ecc.BN254, "bn254"}, + {ecc.BLS12_377, "bls12377"}, + {ecc.BW6_761, "bw6761"}, + } + + for _, tc := range testCases { + t.Run(tc.curveName, func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + + // a small test-only canonical SRS, written with the store's naming scheme + canonical := newTestCanonicalSRS(t, tc.curveID, 16) + dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_16_%s_aztec.memdump", tc.curveName))) + + store, err := NewSRSStore(dir) + assert.NoError(err) + + lagrange, err := toLagrange(canonical, 8) + assert.NoError(err) + assert.NoError(store.cacheLagrange(lagrange, 8, tc.curveID, "aztec")) + + // a fresh store must pick the cached file up and be able to load it + fresh, err := NewSRSStore(dir) + assert.NoError(err) + found := false + for _, entry := range fresh.entriesSnapshot(tc.curveID) { + if !entry.isCanonical && entry.size == 8 { + found = true + reloaded := kzg.NewSRS(tc.curveID) + data, err := os.ReadFile(entry.path) + assert.NoError(err) + assert.NoError(reloaded.ReadDump(bytes.NewReader(data)), "cached lagrange SRS must be loadable") + assertSameVk(t, canonical, reloaded) + } + } + assert.True(found, "derived lagrange SRS was not persisted under a loadable name") + }) + } + + t.Run("concurrent_calls_register_once", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + store, err := NewSRSStore(dir) + assert.NoError(err) + lagrange, err := toLagrange(canonical, 8) + assert.NoError(err) + + // concurrent snapshot readers and cache writers must be race-free + // (run with -race) and must register exactly one entry + errs := make(chan error, 8) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = store.entriesSnapshot(ecc.BN254) + errs <- store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec") + }() + } + wg.Wait() + close(errs) + for err := range errs { + assert.NoError(err, "concurrent cacheLagrange calls must all succeed") + } + count := 0 + for _, entry := range store.entriesSnapshot(ecc.BN254) { + if !entry.isCanonical && entry.size == 8 { + count++ + } + } + assert.Equal(1, count, "concurrent caching must register exactly one entry") + }) + + t.Run("leaves_no_temp_files", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + store, err := NewSRSStore(dir) + assert.NoError(err) + lagrange, err := toLagrange(canonical, 8) + assert.NoError(err) + assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec")) + + leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) + assert.NoError(err) + assert.Empty(leftovers, "cacheLagrange must not leave temp files behind") + }) + + t.Run("failed_publish_cleans_up_its_temp", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + store, err := NewSRSStore(dir) + assert.NoError(err) + lagrange, err := toLagrange(canonical, 8) + assert.NoError(err) + + // a directory squatting on the final name makes the rename fail + assert.NoError(os.Mkdir(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump"), 0o700)) + assert.Error(store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec"), "publish must fail when the final name is taken by a directory") + + leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) + assert.NoError(err) + assert.Empty(leftovers, "a failed publish must not leave temp files behind") + }) +} + +func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + + cs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &circuit{make([]frontend.Variable, 1)}) + assert.NoError(err) + canonicalSize, lagrangeSize := plonk.SRSSize(cs) + canonical, _, err := unsafekzg.NewSRS(cs) + assert.NoError(err) + + // seed the store with ONLY the canonical dump: GetSRS must derive the + // lagrange SRS and persist it + dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + + store, err := NewSRSStore(dir) + assert.NoError(err) + _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + + cachedPath := filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) + info, err := os.Stat(cachedPath) + assert.NoError(err, "GetSRS must persist the derived lagrange SRS") + assert.Equal(os.FileMode(0o644), info.Mode().Perm(), "the cached dump must be world-readable like the ceremony files") + + // a fresh store must serve the same request from the cached file, without + // republishing it + fresh, err := NewSRSStore(dir) + assert.NoError(err) + _, lagrangeSRS, err = fresh.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + after, err := os.Stat(cachedPath) + assert.NoError(err) + assert.Equal(info.ModTime(), after.ModTime(), "a cache hit must not rewrite the file") + + for _, bad := range []struct { + name string + content []byte + }{ + {"garbage", []byte("garbage")}, + // a valid but wrong-size dump: catches the pkG1Len length guard + {"undersized", dumpBytes(t, mustToLagrange(t, canonical, lagrangeSize/2))}, + } { + t.Run("re_derives_over_unloadable_cache/"+bad.name, func(t *testing.T) { + assert := require.New(t) + // an unloadable lagrange dump beside a valid canonical: GetSRS must + // warn, re-derive, and overwrite the bad file in place + subDir := t.TempDir() + dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + badPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) + assert.NoError(os.WriteFile(badPath, bad.content, 0o600)) + + broken, err := NewSRSStore(subDir) + assert.NoError(err) + _, lagrangeSRS, err := broken.GetSRS(context.TODO(), cs) + assert.NoError(err, "an unloadable cached lagrange SRS must not fail GetSRS") + assert.NotNil(lagrangeSRS) + + // the bad file was repaired in place with a loadable, correct-size dump + reloaded := kzg.NewSRS(ecc.BN254) + data, err := os.ReadFile(badPath) + assert.NoError(err) + assert.NoError(reloaded.ReadDump(bytes.NewReader(data)), "the repaired cache file must be loadable") + assert.Equal(lagrangeSize, pkG1Len(reloaded), "the repaired cache file must have the right size") + assertSameVk(t, canonical, reloaded) + }) + } + + t.Run("never_pairs_across_ceremonies", func(t *testing.T) { + assert := require.New(t) + // a valid same-size lagrange dump from a DIFFERENT ceremony must be + // skipped, left untouched, and a matching one derived instead + subDir := t.TempDir() + dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + lagrange, err := toLagrange(canonical, lagrangeSize) + assert.NoError(err) + celoPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_celo.memdump", lagrangeSize)) + dumpToFile(t, lagrange, celoPath) + celoBefore, err := os.Stat(celoPath) + assert.NoError(err) + + store, err := NewSRSStore(subDir) + assert.NoError(err) + _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + + celoAfter, err := os.Stat(celoPath) + assert.NoError(err, "the mismatched-ceremony dump must be left untouched") + assert.Equal(celoBefore.ModTime(), celoAfter.ModTime()) + _, err = os.Stat(filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) + assert.NoError(err, "a lagrange dump matching the canonical's ceremony must be derived and persisted") + }) + + t.Run("read_only_store_dir_is_best_effort", func(t *testing.T) { + assert := require.New(t) + if os.Geteuid() == 0 { + t.Skip("directory permissions are not enforced for root") + } + roDir := t.TempDir() + dumpToFile(t, canonical, filepath.Join(roDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + roStore, err := NewSRSStore(roDir) + assert.NoError(err) + assert.NoError(os.Chmod(roDir, 0o500)) + t.Cleanup(func() { _ = os.Chmod(roDir, 0o700) }) + + _, lagrangeSRS, err := roStore.GetSRS(context.TODO(), cs) + assert.NoError(err, "a read-only store directory must not fail GetSRS") + assert.NotNil(lagrangeSRS) + _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) + assert.True(os.IsNotExist(err), "nothing must be published to a read-only directory") + }) +} + +// mustToLagrange derives a lagrange SRS or fails the test. +func mustToLagrange(t *testing.T, canonical kzg.SRS, size int) kzg.SRS { + t.Helper() + l, err := toLagrange(canonical, size) + require.NoError(t, err) + return l +} + +// dumpBytes serializes an SRS to its on-disk dump form. +func dumpBytes(t *testing.T, srs kzg.SRS) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, srs.WriteDump(&buf)) + return buf.Bytes() +} From c8cbbf4c4cd0828c6794f4e2ef44a9fe1bc1fb0a Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Mon, 27 Jul 2026 17:08:03 +0200 Subject: [PATCH 05/31] refactor(prover): match cached lagrange SRS by verifying key, not filename Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 32 +++++++++++++++++++++++++------ prover/circuits/srs_store_test.go | 28 +++++++++++++++------------ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 9c6663e9897..3063d816679 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "regexp" @@ -16,6 +17,7 @@ import ( "github.com/consensys/gnark-crypto/kzg" "github.com/consensys/gnark/backend/plonk" "github.com/consensys/gnark/constraint" + "github.com/consensys/linea-monorepo/prover/utils" "github.com/sirupsen/logrus" kzg377 "github.com/consensys/gnark-crypto/ecc/bls12-377/kzg" @@ -150,20 +152,22 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst if entry.isCanonical || entry.size != sizeLagrange { continue } - if entry.source != canonicalEntry.source { - // a lagrange basis from a different ceremony is inconsistent with - // the canonical SRS; skip it rather than mix ceremonies - logrus.Debugf("skipping lagrange SRS %s: ceremony %q != canonical's %q", entry.path, entry.source, canonicalEntry.source) - continue - } srs := kzg.NewSRS(curveID) data, err := os.ReadFile(entry.path) if err == nil { err = srs.ReadDump(bytes.NewReader(data)) } + // catch a wrong-size dump here, where it re-derives, rather than letting + // plonk.Setup reject it later as an unrecoverable error if err == nil && pkG1Len(srs) != sizeLagrange { err = fmt.Errorf("dump has %d points, want %d", pkG1Len(srs), sizeLagrange) } + if err == nil { + // the KZG verifying key is basis-independent, so a lagrange dump + // derived from this canonical SRS must carry the same Vk; a mismatch + // means a stale, foreign-ceremony, or mislabelled dump — re-derive it + err = utils.WriterstoEqual(srsVk(canonicalSRS), srsVk(srs)) + } if err != nil { // a lagrange dump is reconstructible (unlike the canonical SRS): log // and fall back to deriving, which re-persists over this same path @@ -267,6 +271,22 @@ func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curv return nil } +// srsVk exposes the SRS verifying key for equality checks. The Vk is +// basis-independent, so a canonical SRS and a lagrange dump derived from it +// carry the same one. +func srsVk(srs kzg.SRS) io.WriterTo { + switch s := srs.(type) { + case *kzg254.SRS: + return &s.Vk + case *kzg377.SRS: + return &s.Vk + case *kzgbw6.SRS: + return &s.Vk + default: + return nil + } +} + // pkG1Len returns the number of G1 proving-key points in the SRS, or -1. func pkG1Len(srs kzg.SRS) int { switch srs := srs.(type) { diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 43b8e307ef7..c183bc2f1dc 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -261,18 +261,20 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { }) } - t.Run("never_pairs_across_ceremonies", func(t *testing.T) { + t.Run("rejects_lagrange_from_a_different_setup", func(t *testing.T) { assert := require.New(t) - // a valid same-size lagrange dump from a DIFFERENT ceremony must be - // skipped, left untouched, and a matching one derived instead + // a same-size lagrange dump derived from a DIFFERENT canonical SRS (so a + // different verifying key) must be rejected and re-derived, even though + // its filename and point count both match subDir := t.TempDir() dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) - lagrange, err := toLagrange(canonical, lagrangeSize) + + otherCanonical, _, err := unsafekzg.NewSRS(cs) assert.NoError(err) - celoPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_celo.memdump", lagrangeSize)) - dumpToFile(t, lagrange, celoPath) - celoBefore, err := os.Stat(celoPath) + otherLagrange, err := toLagrange(otherCanonical, lagrangeSize) assert.NoError(err) + lagrangePath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) + dumpToFile(t, otherLagrange, lagrangePath) store, err := NewSRSStore(subDir) assert.NoError(err) @@ -280,11 +282,13 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) assert.NotNil(lagrangeSRS) - celoAfter, err := os.Stat(celoPath) - assert.NoError(err, "the mismatched-ceremony dump must be left untouched") - assert.Equal(celoBefore.ModTime(), celoAfter.ModTime()) - _, err = os.Stat(filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) - assert.NoError(err, "a lagrange dump matching the canonical's ceremony must be derived and persisted") + // the foreign dump must have been re-derived over: its Vk now matches + // this canonical + reloaded := kzg.NewSRS(ecc.BN254) + data, err := os.ReadFile(lagrangePath) + assert.NoError(err) + assert.NoError(reloaded.ReadDump(bytes.NewReader(data))) + assertSameVk(t, canonical, reloaded) }) t.Run("read_only_store_dir_is_best_effort", func(t *testing.T) { From ee93032060f4481728e2d7c81b51ca778f8cd164 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Mon, 27 Jul 2026 17:40:42 +0200 Subject: [PATCH 06/31] feat(prover): validate cached lagrange SRS points are on-curve at load Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 42 ++++++++++++++++++++++++++++++- prover/circuits/srs_store_test.go | 10 ++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 3063d816679..f2af255d66b 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -12,12 +12,14 @@ import ( "sort" "strconv" "sync" + "sync/atomic" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/kzg" "github.com/consensys/gnark/backend/plonk" "github.com/consensys/gnark/constraint" "github.com/consensys/linea-monorepo/prover/utils" + "github.com/consensys/linea-monorepo/prover/utils/parallel" "github.com/sirupsen/logrus" kzg377 "github.com/consensys/gnark-crypto/ecc/bls12-377/kzg" @@ -168,6 +170,9 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst // means a stale, foreign-ceremony, or mislabelled dump — re-derive it err = utils.WriterstoEqual(srsVk(canonicalSRS), srsVk(srs)) } + if err == nil { + err = pkG1OnCurve(srs) + } if err != nil { // a lagrange dump is reconstructible (unlike the canonical SRS): log // and fall back to deriving, which re-persists over this same path @@ -225,7 +230,9 @@ func (store *SRSStore) register(curveID ecc.ID, newEntry fsEntry) { // cacheLagrange writes a derived Lagrange SRS into the store's directory using // the naming scheme NewSRSStore parses, and registers it in the index. The dump // is fsync'd and renamed into place atomically, so a torn write cannot appear -// under a trusted name; a bad dump is caught on the next load and re-derived. +// under a trusted name. On load, framing, size, setup-identity (Vk) and +// point-validity (on-curve) errors are all caught and re-derived; substitution +// of validly-encoded points is out of scope, as for every file in the store. func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID, source string) error { curveName, ok := curveFileNames[curveID] if !ok { @@ -271,6 +278,39 @@ func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curv return nil } +// pkG1OnCurve fails if any proving-key point is off-curve. ReadDump is a raw +// copy with no point validation, so this is what actually catches a corrupted +// dump: a random bit-flip virtually never lands on the curve. Runs over points +// already in memory, in parallel — seconds, against hours of re-derivation. +func pkG1OnCurve(srs kzg.SRS) error { + var bad atomic.Int64 + bad.Store(-1) + check := func(isOnCurve func(i int) bool, n int) { + parallel.Execute(n, func(start, stop int) { + for i := start; i < stop; i++ { + if !isOnCurve(i) { + bad.Store(int64(i)) + return + } + } + }) + } + switch s := srs.(type) { + case *kzg254.SRS: + check(func(i int) bool { return s.Pk.G1[i].IsOnCurve() }, len(s.Pk.G1)) + case *kzg377.SRS: + check(func(i int) bool { return s.Pk.G1[i].IsOnCurve() }, len(s.Pk.G1)) + case *kzgbw6.SRS: + check(func(i int) bool { return s.Pk.G1[i].IsOnCurve() }, len(s.Pk.G1)) + default: + return fmt.Errorf("unsupported SRS type %T", srs) + } + if i := bad.Load(); i >= 0 { + return fmt.Errorf("proving-key point %d is not on the curve", i) + } + return nil +} + // srsVk exposes the SRS verifying key for equality checks. The Vk is // basis-independent, so a canonical SRS and a lagrange dump derived from it // carry the same one. diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index c183bc2f1dc..57b033c6147 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -233,6 +233,9 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { content []byte }{ {"garbage", []byte("garbage")}, + // one flipped bit in the point region loads cleanly and passes the + // length check, but leaves an off-curve point — must be re-derived + {"bitflipped", bitflip(dumpBytes(t, mustToLagrange(t, canonical, lagrangeSize)))}, // a valid but wrong-size dump: catches the pkG1Len length guard {"undersized", dumpBytes(t, mustToLagrange(t, canonical, lagrangeSize/2))}, } { @@ -326,3 +329,10 @@ func dumpBytes(t *testing.T, srs kzg.SRS) []byte { require.NoError(t, srs.WriteDump(&buf)) return buf.Bytes() } + +// bitflip flips one bit inside the point region of a serialized dump. +func bitflip(dump []byte) []byte { + out := append([]byte(nil), dump...) + out[len(out)-100] ^= 0x01 + return out +} From 6d99bc6b29c831c952ab699c0f33ac5773e143fd Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Mon, 27 Jul 2026 18:12:18 +0200 Subject: [PATCH 07/31] docs(prover): annotate each load check and correct the lock-safety comment Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index f2af255d66b..edf92c6f7ff 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -29,7 +29,9 @@ import ( type SRSStore struct { // mu guards entries; a file is safe to read unlocked because it is only - // registered after being atomically published, and published files are immutable. + // registered after being atomically published, and a re-publish renames a + // new file over the path — rename(2) swaps the directory entry, so an + // in-flight read keeps its old inode and never sees a mix. mu sync.RWMutex entries map[ecc.ID][]fsEntry rootDir string @@ -157,20 +159,22 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst srs := kzg.NewSRS(curveID) data, err := os.ReadFile(entry.path) if err == nil { + // catches a truncated or unparseable dump err = srs.ReadDump(bytes.NewReader(data)) } - // catch a wrong-size dump here, where it re-derives, rather than letting - // plonk.Setup reject it later as an unrecoverable error if err == nil && pkG1Len(srs) != sizeLagrange { + // catches a wrong-size dump here, where it re-derives, rather than + // letting plonk.Setup reject it later as an unrecoverable error err = fmt.Errorf("dump has %d points, want %d", pkG1Len(srs), sizeLagrange) } if err == nil { - // the KZG verifying key is basis-independent, so a lagrange dump - // derived from this canonical SRS must carry the same Vk; a mismatch - // means a stale, foreign-ceremony, or mislabelled dump — re-derive it + // catches a dump from a different setup: the KZG verifying key is + // basis-independent, so a lagrange dump derived from this canonical + // SRS must carry the same Vk err = utils.WriterstoEqual(srsVk(canonicalSRS), srsVk(srs)) } if err == nil { + // catches bit-rot in the point data, which parses cleanly err = pkG1OnCurve(srs) } if err != nil { From 8a8574ef3e3a9e19c50ee5f09655fc3ec17ebff5 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Mon, 27 Jul 2026 19:21:42 +0200 Subject: [PATCH 08/31] feat(prover): sweep aged orphan temp dumps at store construction Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 26 +++++++++++++++++++++++++- prover/circuits/srs_store_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index edf92c6f7ff..2ff0d78e339 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -11,8 +11,10 @@ import ( "regexp" "sort" "strconv" + "strings" "sync" "sync/atomic" + "time" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/kzg" @@ -44,6 +46,10 @@ type fsEntry struct { source string // the ceremony tag in the file name: aleo, aztec or celo } +// orphanTempMaxAge is how old a temp file must be before store construction +// sweeps it; a live writer refreshes its temp's mtime while streaming the dump. +const orphanTempMaxAge = time.Hour + // curveFileNames maps a curve ID to the token naming it in SRS file names. var curveFileNames = map[ecc.ID]string{ ecc.BLS12_377: "bls12377", @@ -92,6 +98,18 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { fileName := entry.Name() matches := srsRegexp.FindStringSubmatch(fileName) if matches == nil { + // a crash mid-write (e.g. an OOM-kill during a multi-GiB dump) + // orphans a temp file that nothing indexes or reclaims; sweep it + // once it is old enough that no live writer can still own it + if strings.HasPrefix(fileName, "kzg_srs_") && strings.Contains(fileName, ".memdump.tmp") { + if info, err := entry.Info(); err == nil && time.Since(info.ModTime()) > orphanTempMaxAge { + if err := os.Remove(filepath.Join(rootDir, fileName)); err != nil { + logrus.Warnf("could not remove orphaned srs temp file %s: %v", fileName, err) + } else { + logrus.Infof("removed orphaned srs temp file %s", fileName) + } + } + } continue } @@ -122,6 +140,11 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { return srsStore, nil } +// GetSRS returns the canonical and Lagrange SRS for the circuit, deriving and +// persisting the Lagrange form when no loadable dump is on disk. Concurrent +// callers requesting the same missing size each derive independently (the +// store is race-safe but does not deduplicate the work); every in-repo caller +// is sequential today. func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { sizeCanonical, sizeLagrange := plonk.SRSSize(ccs) curveID := fieldToCurve(ccs.Field()) @@ -174,7 +197,8 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst err = utils.WriterstoEqual(srsVk(canonicalSRS), srsVk(srs)) } if err == nil { - // catches bit-rot in the point data, which parses cleanly + // catches corrupted point data — ReadDump copies bytes without + // validating them err = pkG1OnCurve(srs) } if err != nil { diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 57b033c6147..e81e9d1154b 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sync" "testing" + "time" "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark-crypto/kzg" @@ -190,6 +191,31 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) assert.Empty(leftovers, "a failed publish must not leave temp files behind") }) + + t.Run("sweeps_only_aged_orphan_temps", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + + // a crash-orphaned temp (old) and a concurrent writer's temp (fresh) + aged := filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump.tmp111") + fresh := filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump.tmp222") + assert.NoError(os.WriteFile(aged, []byte("dead"), 0o600)) + assert.NoError(os.WriteFile(fresh, []byte("live"), 0o600)) + assert.NoError(os.Chtimes(aged, time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour))) + + store, err := NewSRSStore(dir) + assert.NoError(err) + + _, err = os.Stat(aged) + assert.True(os.IsNotExist(err), "an aged orphan temp must be swept") + _, err = os.Stat(fresh) + assert.NoError(err, "a fresh temp must be spared") + for _, entry := range store.entriesSnapshot(ecc.BN254) { + assert.True(entry.isCanonical, "temp files must never be indexed") + } + }) } func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { From b216d714c85452b978b599f0099b87bcd3b33b4b Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Mon, 27 Jul 2026 19:34:29 +0200 Subject: [PATCH 09/31] docs(prover): orphaned temps are deleted after an hour since last write Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 2ff0d78e339..8a552d0167a 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -46,8 +46,9 @@ type fsEntry struct { source string // the ceremony tag in the file name: aleo, aztec or celo } -// orphanTempMaxAge is how old a temp file must be before store construction -// sweeps it; a live writer refreshes its temp's mtime while streaming the dump. +// orphanTempMaxAge is how long a temp file's last write must lie in the past +// before store construction deletes it; a live writer keeps refreshing its +// temp's mtime while streaming the dump. const orphanTempMaxAge = time.Hour // curveFileNames maps a curve ID to the token naming it in SRS file names. @@ -99,8 +100,9 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { matches := srsRegexp.FindStringSubmatch(fileName) if matches == nil { // a crash mid-write (e.g. an OOM-kill during a multi-GiB dump) - // orphans a temp file that nothing indexes or reclaims; sweep it - // once it is old enough that no live writer can still own it + // orphans a temp file that nothing indexes or reclaims; delete it + // once its last write is more than an hour old — a live writer's + // temp is always newer than that if strings.HasPrefix(fileName, "kzg_srs_") && strings.Contains(fileName, ".memdump.tmp") { if info, err := entry.Info(); err == nil && time.Since(info.ModTime()) > orphanTempMaxAge { if err := os.Remove(filepath.Join(rootDir, fileName)); err != nil { From cd8d7ff49d22d602209a9d9fb0bd80c5e3fcdd57 Mon Sep 17 00:00:00 2001 From: Marelize Date: Tue, 28 Jul 2026 13:24:33 +0200 Subject: [PATCH 10/31] test(prover): use a genuinely different setup in the SRS rejection test Signed-off-by: Marelize --- prover/circuits/srs_store_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index e81e9d1154b..90e64ce39f1 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -298,7 +298,11 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { subDir := t.TempDir() dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) - otherCanonical, _, err := unsafekzg.NewSRS(cs) + // unsafekzg.NewSRS memoises on (curve, size, toxic value), so calling it + // again with no toxic value returns the very same SRS and the dump below + // would carry a matching Vk: an explicit toxic value is what makes this + // a different setup at all + otherCanonical, _, err := unsafekzg.NewSRS(cs, unsafekzg.WithToxicValue(big.NewInt(7919))) assert.NoError(err) otherLagrange, err := toLagrange(otherCanonical, lagrangeSize) assert.NoError(err) From d35bf706e1586fd13a8859a45f1e16b2c51d1961 Mon Sep 17 00:00:00 2001 From: Marelize Date: Tue, 28 Jul 2026 13:26:01 +0200 Subject: [PATCH 11/31] feat(prover): tag locally derived lagrange dumps as derived, not as ceremony material Signed-off-by: Marelize --- prover/circuits/srs_store.go | 36 +++++++++---- prover/circuits/srs_store_test.go | 89 ++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 27 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 8a552d0167a..885ee2052e4 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -43,9 +43,21 @@ type fsEntry struct { isCanonical bool size int path string - source string // the ceremony tag in the file name: aleo, aztec or celo + source string // the provenance tag in the file name: aleo, aztec, celo or derived } +// derivedSourceTag is the provenance tag for a Lagrange basis this process +// computed locally, as opposed to one distributed from a ceremony. +// +// A derived basis is only as trustworthy as the canonical SRS it was computed +// from, so it must not inherit that file's ceremony tag. Whoever later lists +// the store, restores a backup, or bakes the directory into an image has to be +// able to tell computed material from attested material, and a file name is +// the only signal they get. Nothing in the store validates that a file tagged +// "aztec" descends from that ceremony, so a tag written by code is a +// provenance claim the code cannot support: never write one. +const derivedSourceTag = "derived" + // orphanTempMaxAge is how long a temp file's last write must lie in the past // before store construction deletes it; a live writer keeps refreshing its // temp's mtime while streaming the dump. @@ -86,7 +98,9 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { srsStore.entries[ecc.BN254] = []fsEntry{} srsStore.entries[ecc.BW6_761] = []fsEntry{} - srsRegexp := regexp.MustCompile(`^(kzg_srs)_(canonical|lagrange)_(\d+)_(bls12377|bn254|bw6761)_(aleo|aztec|celo)\.memdump$`) + // the trailing group is the provenance tag: one of the three ceremonies the + // store may be seeded from, or derivedSourceTag for a locally computed basis + srsRegexp := regexp.MustCompile(`^(kzg_srs)_(canonical|lagrange)_(\d+)_(bls12377|bn254|bw6761)_(aleo|aztec|celo|` + derivedSourceTag + `)\.memdump$`) for _, entry := range dir { if entry.IsDir() { @@ -155,7 +169,6 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst // find the canonical srs var canonicalSRS kzg.SRS - var canonicalEntry fsEntry for _, entry := range entries { if entry.isCanonical && entry.size >= sizeCanonical { canonicalSRS = kzg.NewSRS(curveID) @@ -166,7 +179,6 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst if err := canonicalSRS.ReadDump(bytes.NewReader(data), sizeCanonical); err != nil { return nil, nil, err } - canonicalEntry = entry break } } @@ -205,7 +217,9 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst } if err != nil { // a lagrange dump is reconstructible (unlike the canonical SRS): log - // and fall back to deriving, which re-persists over this same path + // and fall back to deriving. The rejected file is left exactly as it + // is: a derived basis is published under derivedSourceTag, so this + // process never overwrites a file it did not write. logrus.Warnf("could not load lagrange SRS %s, re-deriving it: %v", entry.path, err) continue } @@ -227,7 +241,7 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst // Persist the derived Lagrange SRS so subsequent runs load it from disk // instead of re-deriving it. Best-effort: a failed write must not fail // the caller. - if err := store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID, canonicalEntry.source); err != nil { + if err := store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID); err != nil { logrus.Warnf("could not persist derived lagrange SRS (continuing): %v", err) } } @@ -263,13 +277,17 @@ func (store *SRSStore) register(curveID ecc.ID, newEntry fsEntry) { // under a trusted name. On load, framing, size, setup-identity (Vk) and // point-validity (on-curve) errors are all caught and re-derived; substitution // of validly-encoded points is out of scope, as for every file in the store. -func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID, source string) error { +// +// The published name always carries derivedSourceTag, so the only file this can +// ever replace is one it wrote itself: a ceremony dump on the same path is +// impossible by construction, and a stale derived dump is safe to supersede. +func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID) error { curveName, ok := curveFileNames[curveID] if !ok { return fmt.Errorf("curve not supported: %s", curveID) } - fileName := fmt.Sprintf("kzg_srs_lagrange_%d_%s_%s.memdump", sizeLagrange, curveName, source) + fileName := fmt.Sprintf("kzg_srs_lagrange_%d_%s_%s.memdump", sizeLagrange, curveName, derivedSourceTag) finalPath := filepath.Join(store.rootDir, fileName) f, err := os.CreateTemp(store.rootDir, fileName+".tmp") @@ -302,7 +320,7 @@ func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curv } syncDir(store.rootDir) - store.register(curveID, fsEntry{isCanonical: false, size: sizeLagrange, path: finalPath, source: source}) + store.register(curveID, fsEntry{isCanonical: false, size: sizeLagrange, path: finalPath, source: derivedSourceTag}) logrus.Infof("persisted derived lagrange SRS to %s", finalPath) return nil diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 90e64ce39f1..3257e79432d 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -101,7 +101,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { lagrange, err := toLagrange(canonical, 8) assert.NoError(err) - assert.NoError(store.cacheLagrange(lagrange, 8, tc.curveID, "aztec")) + assert.NoError(store.cacheLagrange(lagrange, 8, tc.curveID)) // a fresh store must pick the cached file up and be able to load it fresh, err := NewSRSStore(dir) @@ -140,7 +140,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { go func() { defer wg.Done() _ = store.entriesSnapshot(ecc.BN254) - errs <- store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec") + errs <- store.cacheLagrange(lagrange, 8, ecc.BN254) }() } wg.Wait() @@ -166,7 +166,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) lagrange, err := toLagrange(canonical, 8) assert.NoError(err) - assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec")) + assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254)) leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) assert.NoError(err) @@ -184,8 +184,8 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) // a directory squatting on the final name makes the rename fail - assert.NoError(os.Mkdir(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump"), 0o700)) - assert.Error(store.cacheLagrange(lagrange, 8, ecc.BN254, "aztec"), "publish must fail when the final name is taken by a directory") + assert.NoError(os.Mkdir(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_derived.memdump"), 0o700)) + assert.Error(store.cacheLagrange(lagrange, 8, ecc.BN254), "publish must fail when the final name is taken by a directory") leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) assert.NoError(err) @@ -238,9 +238,12 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) assert.NotNil(lagrangeSRS) - cachedPath := filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) + // published under the derived tag, never under the canonical file's ceremony + cachedPath := filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) info, err := os.Stat(cachedPath) assert.NoError(err, "GetSRS must persist the derived lagrange SRS") + _, err = os.Stat(filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) + assert.True(os.IsNotExist(err), "a derived basis must not be published under a ceremony tag") assert.Equal(os.FileMode(0o644), info.Mode().Perm(), "the cached dump must be world-readable like the ceremony files") // a fresh store must serve the same request from the cached file, without @@ -265,10 +268,11 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { // a valid but wrong-size dump: catches the pkG1Len length guard {"undersized", dumpBytes(t, mustToLagrange(t, canonical, lagrangeSize/2))}, } { - t.Run("re_derives_over_unloadable_cache/"+bad.name, func(t *testing.T) { + t.Run("re_derives_beside_unloadable_cache/"+bad.name, func(t *testing.T) { assert := require.New(t) // an unloadable lagrange dump beside a valid canonical: GetSRS must - // warn, re-derive, and overwrite the bad file in place + // warn, re-derive, and publish under the derived tag while leaving + // the ceremony-tagged file it did not write completely alone subDir := t.TempDir() dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) badPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) @@ -280,12 +284,18 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err, "an unloadable cached lagrange SRS must not fail GetSRS") assert.NotNil(lagrangeSRS) - // the bad file was repaired in place with a loadable, correct-size dump + // the operator's file is untouched, byte for byte + stillBad, err := os.ReadFile(badPath) + assert.NoError(err, "the rejected dump must not be deleted") + assert.Equal(bad.content, stillBad, "the rejected dump must not be overwritten") + + // and a loadable, correct-size, same-setup dump exists beside it + derivedPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) reloaded := kzg.NewSRS(ecc.BN254) - data, err := os.ReadFile(badPath) - assert.NoError(err) - assert.NoError(reloaded.ReadDump(bytes.NewReader(data)), "the repaired cache file must be loadable") - assert.Equal(lagrangeSize, pkG1Len(reloaded), "the repaired cache file must have the right size") + data, err := os.ReadFile(derivedPath) + assert.NoError(err, "a derived dump must have been published") + assert.NoError(reloaded.ReadDump(bytes.NewReader(data)), "the derived dump must be loadable") + assert.Equal(lagrangeSize, pkG1Len(reloaded), "the derived dump must have the right size") assertSameVk(t, canonical, reloaded) }) } @@ -308,6 +318,8 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) lagrangePath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) dumpToFile(t, otherLagrange, lagrangePath) + foreignBefore, err := os.ReadFile(lagrangePath) + assert.NoError(err) store, err := NewSRSStore(subDir) assert.NoError(err) @@ -315,11 +327,14 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) assert.NotNil(lagrangeSRS) - // the foreign dump must have been re-derived over: its Vk now matches - // this canonical - reloaded := kzg.NewSRS(ecc.BN254) - data, err := os.ReadFile(lagrangePath) + // the foreign dump is left alone; a matching basis is published beside it + foreignAfter, err := os.ReadFile(lagrangePath) assert.NoError(err) + assert.Equal(foreignBefore, foreignAfter, "a dump from another setup must not be overwritten") + + reloaded := kzg.NewSRS(ecc.BN254) + data, err := os.ReadFile(filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) + assert.NoError(err, "a derived dump matching this canonical must have been published") assert.NoError(reloaded.ReadDump(bytes.NewReader(data))) assertSameVk(t, canonical, reloaded) }) @@ -339,11 +354,49 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { _, lagrangeSRS, err := roStore.GetSRS(context.TODO(), cs) assert.NoError(err, "a read-only store directory must not fail GetSRS") assert.NotNil(lagrangeSRS) - _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) + _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) assert.True(os.IsNotExist(err), "nothing must be published to a read-only directory") }) } +// TestSRSStore_DerivedDumpsNeverClaimACeremony pins the provenance rule: a +// locally computed basis is published under derivedSourceTag whatever ceremony +// the canonical SRS it came from was tagged with, and is loadable again from +// that name. +func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { + for _, ceremony := range []string{"aleo", "aztec", "celo"} { + t.Run("canonical_"+ceremony, func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_16_bn254_%s.memdump", ceremony))) + + store, err := NewSRSStore(dir) + assert.NoError(err) + lagrange, err := toLagrange(canonical, 8) + assert.NoError(err) + assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254)) + + assert.FileExists(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_derived.memdump")) + _, err = os.Stat(filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_8_bn254_%s.memdump", ceremony))) + assert.True(os.IsNotExist(err), "a derived basis must never inherit the canonical file's ceremony tag") + + // the derived tag must round-trip through the store's own parser + fresh, err := NewSRSStore(dir) + assert.NoError(err) + indexed := false + for _, entry := range fresh.entriesSnapshot(ecc.BN254) { + if !entry.isCanonical && entry.size == 8 { + indexed = true + assert.Equal(derivedSourceTag, entry.source) + } + } + assert.True(indexed, "a derived dump must be indexed on the next construction") + }) + } +} + // mustToLagrange derives a lagrange SRS or fails the test. func mustToLagrange(t *testing.T, canonical kzg.SRS, size int) kzg.SRS { t.Helper() From efe9c90141574c3f51b9cd84a0a4d98b6bd4821f Mon Sep 17 00:00:00 2001 From: Marelize Date: Tue, 28 Jul 2026 13:29:58 +0200 Subject: [PATCH 12/31] refactor(prover): move lagrange persistence out of the GetSRS read path Signed-off-by: Marelize --- prover/circuits/srs_provider.go | 11 ++++++ prover/circuits/srs_store.go | 66 ++++++++++++++++++++++--------- prover/circuits/srs_store_test.go | 51 ++++++++++++++++++------ prover/cmd/prover/cmd/setup.go | 10 +++++ 4 files changed, 109 insertions(+), 29 deletions(-) diff --git a/prover/circuits/srs_provider.go b/prover/circuits/srs_provider.go index d12945b2616..338b0787d67 100644 --- a/prover/circuits/srs_provider.go +++ b/prover/circuits/srs_provider.go @@ -12,6 +12,17 @@ type SRSProvider interface { GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) } +// LagrangePersister is implemented by SRS providers that can pre-compute the +// Lagrange basis and leave it on disk for later runs. +// +// It is deliberately separate from SRSProvider: obtaining an SRS is something +// every prove path does, whereas writing into the SRS directory is a +// provisioning action. Keeping the write off the interface every caller holds +// is what stops it from happening implicitly. +type LagrangePersister interface { + DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem) error +} + type UnsafeSRSProvider struct { } diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 885ee2052e4..00e38c1feb6 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -156,12 +156,48 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { return srsStore, nil } -// GetSRS returns the canonical and Lagrange SRS for the circuit, deriving and -// persisting the Lagrange form when no loadable dump is on disk. Concurrent -// callers requesting the same missing size each derive independently (the -// store is race-safe but does not deduplicate the work); every in-repo caller -// is sequential today. +// GetSRS returns the canonical and Lagrange SRS for the circuit. +// +// It only reads. When no loadable Lagrange dump is on disk the basis is derived +// in memory and discarded with the process, exactly as it was before the store +// learned to cache. Populating the store is DeriveAndPersistLagrange's job, so +// that a prove-time read can never mutate the SRS directory: an operator can +// mount it read-only and still be sure the fast path is available, because +// whether the dump exists was decided at provisioning time, not by whichever +// process happened to ask first. func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { + canonicalSRS, lagrangeSRS, _, err := store.resolveSRS(ccs) + return canonicalSRS, lagrangeSRS, err +} + +// DeriveAndPersistLagrange makes the Lagrange basis for ccs available on disk, +// so later runs load it instead of spending hours re-deriving it. It is a no-op +// when a loadable dump is already there, and it is the only path in the store +// that writes. +// +// Best-effort is the caller's choice here rather than the store's: the error is +// returned so an explicit provisioning step can report it, where a prove-time +// read had to swallow it. +func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem) error { + _, lagrangeSRS, derived, err := store.resolveSRS(ccs) + if err != nil { + return err + } + if !derived { + // a loadable dump is already on disk; nothing to publish + return nil + } + _, sizeLagrange := plonk.SRSSize(ccs) + return store.cacheLagrange(lagrangeSRS, sizeLagrange, fieldToCurve(ccs.Field())) +} + +// resolveSRS loads the canonical SRS and either loads or derives the matching +// Lagrange basis, reporting whether it had to derive. It never writes. +// +// Concurrent callers requesting the same missing size each derive independently +// (the store is race-safe but does not deduplicate the work); every in-repo +// caller is sequential today. +func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, bool, error) { sizeCanonical, sizeLagrange := plonk.SRSSize(ccs) curveID := fieldToCurve(ccs.Field()) @@ -174,17 +210,17 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst canonicalSRS = kzg.NewSRS(curveID) data, err := os.ReadFile(entry.path) if err != nil { - return nil, nil, err + return nil, nil, false, err } if err := canonicalSRS.ReadDump(bytes.NewReader(data), sizeCanonical); err != nil { - return nil, nil, err + return nil, nil, false, err } break } } if canonicalSRS == nil { - return nil, nil, fmt.Errorf("could not find canonical SRS for curve %s and size %d", curveID, sizeCanonical) + return nil, nil, false, fmt.Errorf("could not find canonical SRS for curve %s and size %d", curveID, sizeCanonical) } // find the lagrange srs @@ -233,20 +269,14 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst panic("canonical SRS is smaller than lagrange SRS") } logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d", sizeCanonical, sizeLagrange) - var err error - lagrangeSRS, err = toLagrange(canonicalSRS, sizeLagrange) + lagrangeSRS, err := toLagrange(canonicalSRS, sizeLagrange) if err != nil { - return nil, nil, err - } - // Persist the derived Lagrange SRS so subsequent runs load it from disk - // instead of re-deriving it. Best-effort: a failed write must not fail - // the caller. - if err := store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID); err != nil { - logrus.Warnf("could not persist derived lagrange SRS (continuing): %v", err) + return nil, nil, false, err } + return canonicalSRS, lagrangeSRS, true, nil } - return canonicalSRS, lagrangeSRS, nil + return canonicalSRS, lagrangeSRS, false, nil } // entriesSnapshot returns a copy of the entries for curveID, safe to iterate unlocked. diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 3257e79432d..f71dbc07309 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -7,6 +7,7 @@ import ( "math/big" "os" "path/filepath" + "sort" "sync" "testing" "time" @@ -218,7 +219,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { }) } -func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { +func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { assert := require.New(t) dir := t.TempDir() @@ -228,31 +229,39 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { canonical, _, err := unsafekzg.NewSRS(cs) assert.NoError(err) - // seed the store with ONLY the canonical dump: GetSRS must derive the - // lagrange SRS and persist it + // seed the store with ONLY the canonical dump dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + cachedPath := filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) - store, err := NewSRSStore(dir) + // reading must not write: GetSRS derives in memory and leaves no trace + reader, err := NewSRSStore(dir) assert.NoError(err) - _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) + _, lagrangeSRS, err := reader.GetSRS(context.TODO(), cs) assert.NoError(err) - assert.NotNil(lagrangeSRS) + assert.NotNil(lagrangeSRS, "GetSRS must still derive the basis in memory") + _, err = os.Stat(cachedPath) + assert.True(os.IsNotExist(err), "GetSRS must not write into the SRS directory") + assert.Equal([]string{fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize)}, dirNames(t, dir), + "a read must leave the directory exactly as it was") - // published under the derived tag, never under the canonical file's ceremony - cachedPath := filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) + // provisioning writes, under the derived tag and world-readable + store, err := NewSRSStore(dir) + assert.NoError(err) + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs)) info, err := os.Stat(cachedPath) - assert.NoError(err, "GetSRS must persist the derived lagrange SRS") + assert.NoError(err, "DeriveAndPersistLagrange must publish the derived lagrange SRS") _, err = os.Stat(filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) assert.True(os.IsNotExist(err), "a derived basis must not be published under a ceremony tag") assert.Equal(os.FileMode(0o644), info.Mode().Perm(), "the cached dump must be world-readable like the ceremony files") - // a fresh store must serve the same request from the cached file, without - // republishing it + // a fresh store serves the request from the cached file without rewriting it, + // and a second provisioning call is a no-op fresh, err := NewSRSStore(dir) assert.NoError(err) _, lagrangeSRS, err = fresh.GetSRS(context.TODO(), cs) assert.NoError(err) assert.NotNil(lagrangeSRS) + assert.NoError(fresh.DeriveAndPersistLagrange(context.TODO(), cs)) after, err := os.Stat(cachedPath) assert.NoError(err) assert.Equal(info.ModTime(), after.ModTime(), "a cache hit must not rewrite the file") @@ -283,6 +292,7 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { _, lagrangeSRS, err := broken.GetSRS(context.TODO(), cs) assert.NoError(err, "an unloadable cached lagrange SRS must not fail GetSRS") assert.NotNil(lagrangeSRS) + assert.NoError(broken.DeriveAndPersistLagrange(context.TODO(), cs)) // the operator's file is untouched, byte for byte stillBad, err := os.ReadFile(badPath) @@ -326,6 +336,7 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) assert.NoError(err) assert.NotNil(lagrangeSRS) + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs)) // the foreign dump is left alone; a matching basis is published beside it foreignAfter, err := os.ReadFile(lagrangePath) @@ -354,6 +365,11 @@ func TestSRSStore_GetSRS_PersistsDerivedLagrange(t *testing.T) { _, lagrangeSRS, err := roStore.GetSRS(context.TODO(), cs) assert.NoError(err, "a read-only store directory must not fail GetSRS") assert.NotNil(lagrangeSRS) + + // provisioning returns the failure rather than swallowing it, so an + // operator running it deliberately finds out that nothing was written + assert.Error(roStore.DeriveAndPersistLagrange(context.TODO(), cs), + "provisioning must report that it could not write") _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) assert.True(os.IsNotExist(err), "nothing must be published to a read-only directory") }) @@ -397,6 +413,19 @@ func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { } } +// dirNames lists the entry names in dir, sorted. +func dirNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + return names +} + // mustToLagrange derives a lagrange SRS or fails the test. func mustToLagrange(t *testing.T, canonical kzg.SRS, size int) kzg.SRS { t.Helper() diff --git a/prover/cmd/prover/cmd/setup.go b/prover/cmd/prover/cmd/setup.go index 55418b27c62..a15f3879c38 100644 --- a/prover/cmd/prover/cmd/setup.go +++ b/prover/cmd/prover/cmd/setup.go @@ -220,6 +220,16 @@ func updateSetup(ctx context.Context, cfg *config.Config, force bool, } } + // Provisioning, not proving: this is the one place allowed to write into the + // SRS directory, so a later prove-time read finds the Lagrange basis already + // there instead of spending hours re-deriving it. Best-effort, since the + // setup itself derives the basis in memory either way. + if persister, ok := srsProvider.(circuits.LagrangePersister); ok { + if err := persister.DeriveAndPersistLagrange(ctx, ccs); err != nil { + logrus.Warnf("could not persist derived lagrange SRS for %s (continuing): %v", circuit, err) + } + } + // run the actual setup logrus.Infof("plonk setup for %s", circuit) setup, err := circuits.MakeSetup(ctx, circuit, ccs, srsProvider, extraFlags) From 9f45b408956e12698949ee3e24ff855a249106ad Mon Sep 17 00:00:00 2001 From: Marelize Date: Tue, 28 Jul 2026 13:33:01 +0200 Subject: [PATCH 13/31] feat(prover): gate derived-SRS persistence behind an opt-in flag, default off Signed-off-by: Marelize --- prover/cmd/prover/cmd/setup.go | 7 +++--- prover/config/config.go | 10 ++++++++ prover/config/config_test.go | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/prover/cmd/prover/cmd/setup.go b/prover/cmd/prover/cmd/setup.go index a15f3879c38..762d7f7e865 100644 --- a/prover/cmd/prover/cmd/setup.go +++ b/prover/cmd/prover/cmd/setup.go @@ -222,9 +222,10 @@ func updateSetup(ctx context.Context, cfg *config.Config, force bool, // Provisioning, not proving: this is the one place allowed to write into the // SRS directory, so a later prove-time read finds the Lagrange basis already - // there instead of spending hours re-deriving it. Best-effort, since the - // setup itself derives the basis in memory either way. - if persister, ok := srsProvider.(circuits.LagrangePersister); ok { + // there instead of spending hours re-deriving it. Opt-in, because it makes + // the SRS directory writable; best-effort, since the setup derives the basis + // in memory either way. + if persister, ok := srsProvider.(circuits.LagrangePersister); ok && cfg.PersistDerivedSRS { if err := persister.DeriveAndPersistLagrange(ctx, ccs); err != nil { logrus.Warnf("could not persist derived lagrange SRS for %s (continuing): %v", circuit, err) } diff --git a/prover/config/config.go b/prover/config/config.go index 1d6c4594bb8..931a39be8d3 100644 --- a/prover/config/config.go +++ b/prover/config/config.go @@ -146,6 +146,16 @@ type Config struct { // accessed (prover). The file structure is described in TODO @gbotrel. AssetsDir string `mapstructure:"assets_dir"` + // PersistDerivedSRS lets `prover setup` write the Lagrange basis it derives + // into the SRS directory, so later runs load it instead of spending hours + // re-deriving it. Off by default, because turning it on means the SRS + // directory stops being read-only trust-root material and becomes something + // a process writes to: worth it for a self-hoster who has no pre-derived + // dumps, not worth it for a deployment whose assets volume is mounted + // read-only, snapshotted, or verified by hash. Nothing else in the prover + // ever writes there, whatever this is set to. + PersistDerivedSRS bool `mapstructure:"persist_derived_srs"` + Controller Controller Execution Execution DataAvailability DataAvailability `mapstructure:"data_availability"` diff --git a/prover/config/config_test.go b/prover/config/config_test.go index e392a182893..06590b7a70c 100644 --- a/prover/config/config_test.go +++ b/prover/config/config_test.go @@ -76,3 +76,47 @@ func TestMustFindModuleLimitsReturnsLongestMatch(t *testing.T) { } } } + +func TestPersistDerivedSRSDefaultsOff(t *testing.T) { + assert := require.New(t) + + // writing into the SRS directory must never be something an operator gets by + // omission: with the key absent, it stays off + v := viper.New() + v.SetConfigType("toml") + assert.NoError(v.ReadConfig(strings.NewReader("assets_dir = \"/tmp/assets\"\n"))) + var cfg Config + assert.NoError(v.Unmarshal(&cfg)) + assert.False(cfg.PersistDerivedSRS, "persist_derived_srs must default to false") + + // and it is settable for the self-hoster who wants it + v = viper.New() + v.SetConfigType("toml") + assert.NoError(v.ReadConfig(strings.NewReader("persist_derived_srs = true\n"))) + var optedIn Config + assert.NoError(v.Unmarshal(&optedIn)) + assert.True(optedIn.PersistDerivedSRS) +} + +func TestShippedConfigsDoNotOptIntoSRSWrites(t *testing.T) { + assert := require.New(t) + + // none of the checked-in configs may turn the write on: a deployment that + // wants it has to say so itself + files, err := os.ReadDir(".") + assert.NoError(err) + checked := 0 + for _, f := range files { + if !strings.HasPrefix(f.Name(), "config-") || !strings.HasSuffix(f.Name(), ".toml") { + continue + } + v := viper.New() + v.SetConfigFile(f.Name()) + assert.NoError(v.ReadInConfig(), f.Name()) + var cfg Config + assert.NoError(v.Unmarshal(&cfg), f.Name()) + assert.False(cfg.PersistDerivedSRS, "%s must not opt into SRS writes", f.Name()) + checked++ + } + assert.Greater(checked, 0, "no config files were checked") +} From e9aa8e1571e01bc3eed4a485d5f8579eab59fd0b Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Sat, 1 Aug 2026 21:00:48 +0200 Subject: [PATCH 14/31] feat(prover): persist the derived SRS at setup by default, backfilling even when circuits are current Signed-off-by: Coenie Beyers --- prover/cmd/prover/cmd/setup.go | 24 +++++++------- prover/config/config.go | 13 ++++---- prover/config/config_default.go | 5 +++ prover/config/config_test.go | 56 +++++++++++++++++++-------------- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/prover/cmd/prover/cmd/setup.go b/prover/cmd/prover/cmd/setup.go index 762d7f7e865..e2ac1f147dc 100644 --- a/prover/cmd/prover/cmd/setup.go +++ b/prover/cmd/prover/cmd/setup.go @@ -202,6 +202,19 @@ func updateSetup(ctx context.Context, cfg *config.Config, force bool, manifestPath := filepath.Join(setupPath, config.ManifestFileName) logrus.Infof("Manifest path: %s", manifestPath) + // Provisioning, not proving: this is the one place allowed to write into the + // SRS directory, so a later prove-time read finds the Lagrange basis already + // there instead of spending hours re-deriving it. It runs before the + // skip-if-already-setup check so that re-running setup against current + // assets still backfills a missing dump, and it is a no-op when a loadable + // dump is on disk. Best-effort, since setup derives the basis in memory + // either way; persist_derived_srs = false turns it off. + if persister, ok := srsProvider.(circuits.LagrangePersister); ok && cfg.PersistDerivedSRS { + if err := persister.DeriveAndPersistLagrange(ctx, ccs); err != nil { + logrus.Warnf("could not persist derived lagrange SRS for %s (continuing): %v", circuit, err) + } + } + // check if setup can be skipped if !force { // we may want to skip setup if the files already exist @@ -220,17 +233,6 @@ func updateSetup(ctx context.Context, cfg *config.Config, force bool, } } - // Provisioning, not proving: this is the one place allowed to write into the - // SRS directory, so a later prove-time read finds the Lagrange basis already - // there instead of spending hours re-deriving it. Opt-in, because it makes - // the SRS directory writable; best-effort, since the setup derives the basis - // in memory either way. - if persister, ok := srsProvider.(circuits.LagrangePersister); ok && cfg.PersistDerivedSRS { - if err := persister.DeriveAndPersistLagrange(ctx, ccs); err != nil { - logrus.Warnf("could not persist derived lagrange SRS for %s (continuing): %v", circuit, err) - } - } - // run the actual setup logrus.Infof("plonk setup for %s", circuit) setup, err := circuits.MakeSetup(ctx, circuit, ccs, srsProvider, extraFlags) diff --git a/prover/config/config.go b/prover/config/config.go index 931a39be8d3..85f74080ee1 100644 --- a/prover/config/config.go +++ b/prover/config/config.go @@ -147,12 +147,13 @@ type Config struct { AssetsDir string `mapstructure:"assets_dir"` // PersistDerivedSRS lets `prover setup` write the Lagrange basis it derives - // into the SRS directory, so later runs load it instead of spending hours - // re-deriving it. Off by default, because turning it on means the SRS - // directory stops being read-only trust-root material and becomes something - // a process writes to: worth it for a self-hoster who has no pre-derived - // dumps, not worth it for a deployment whose assets volume is mounted - // read-only, snapshotted, or verified by hash. Nothing else in the prover + // into the SRS directory, so later runs load it in seconds instead of + // spending hours re-deriving it. On by default: a missing dump is otherwise + // re-derived silently on every prover start, and the write only ever happens + // during setup — a deliberate provisioning action — never at prove time. Set + // it to false for a deployment that wants its SRS directory strictly + // immutable (read-only mount, snapshotted, or verified by hash); on such a + // volume the write degrades to a warning anyway. Nothing else in the prover // ever writes there, whatever this is set to. PersistDerivedSRS bool `mapstructure:"persist_derived_srs"` diff --git a/prover/config/config_default.go b/prover/config/config_default.go index 1e635f79e2f..92607dc9b05 100644 --- a/prover/config/config_default.go +++ b/prover/config/config_default.go @@ -43,6 +43,11 @@ func setDefaultValues() { viper.SetDefault("execution.ignore_compatibility_check", false) viper.SetDefault("execution.serialization", false) + // persisting the derived lagrange SRS at setup is the default cure for + // silently re-deriving it for hours on every prover start; immutable-SRS + // deployments opt out explicitly + viper.SetDefault("persist_derived_srs", true) + viper.SetDefault("data_availability.max_nb_batches", 100) viper.SetDefault("data_availability.max_uncompressed_nb_bytes", v1.MaxUncompressedBytes) viper.SetDefault("data_availability.dict_nb_bytes", 65536) diff --git a/prover/config/config_test.go b/prover/config/config_test.go index 06590b7a70c..6487b8098ce 100644 --- a/prover/config/config_test.go +++ b/prover/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "regexp" "strings" "testing" @@ -77,32 +78,41 @@ func TestMustFindModuleLimitsReturnsLongestMatch(t *testing.T) { } } -func TestPersistDerivedSRSDefaultsOff(t *testing.T) { +func TestPersistDerivedSRSDefaultsOn(t *testing.T) { assert := require.New(t) - // writing into the SRS directory must never be something an operator gets by - // omission: with the key absent, it stays off - v := viper.New() - v.SetConfigType("toml") - assert.NoError(v.ReadConfig(strings.NewReader("assets_dir = \"/tmp/assets\"\n"))) - var cfg Config - assert.NoError(v.Unmarshal(&cfg)) - assert.False(cfg.PersistDerivedSRS, "persist_derived_srs must default to false") - - // and it is settable for the self-hoster who wants it - v = viper.New() - v.SetConfigType("toml") - assert.NoError(v.ReadConfig(strings.NewReader("persist_derived_srs = true\n"))) - var optedIn Config - assert.NoError(v.Unmarshal(&optedIn)) - assert.True(optedIn.PersistDerivedSRS) + // a missing lagrange dump is otherwise re-derived silently for hours on + // every prover start, so persistence at setup is what an operator gets by + // omission; the default is applied by the real loading path + // the smallest config the unchecked loading path accepts: the layer2 + // addresses are parsed unconditionally, even without validation + minimal := `assets_dir = "/tmp/assets" +[layer2] +message_service_contract = "0x0000000000000000000000000000000000000000" +coin_base = "0x0000000000000000000000000000000000000000" +` + dir := t.TempDir() + path := filepath.Join(dir, "config-test.toml") + assert.NoError(os.WriteFile(path, []byte(minimal), 0o600)) + cfg, err := NewConfigFromFileUnchecked(path) + assert.NoError(err) + assert.True(cfg.PersistDerivedSRS, "persist_derived_srs must default to true") + + // and the immutable-SRS-directory deployment can still opt out (the key is + // top-level, so it must precede the [layer2] table) + assert.NoError(os.WriteFile(path, []byte("persist_derived_srs = false\n"+minimal), 0o600)) + optedOut, err := NewConfigFromFileUnchecked(path) + assert.NoError(err) + assert.False(optedOut.PersistDerivedSRS) } -func TestShippedConfigsDoNotOptIntoSRSWrites(t *testing.T) { +func TestShippedConfigsDoNotOptOutOfSRSWrites(t *testing.T) { assert := require.New(t) - // none of the checked-in configs may turn the write on: a deployment that - // wants it has to say so itself + // persistence at setup is the default cure for silent hours-long + // re-derivation, so a checked-in config must not quietly reintroduce it; + // opting out is a per-deployment decision made in that deployment's own + // config, not in the repo's files, err := os.ReadDir(".") assert.NoError(err) checked := 0 @@ -113,9 +123,9 @@ func TestShippedConfigsDoNotOptIntoSRSWrites(t *testing.T) { v := viper.New() v.SetConfigFile(f.Name()) assert.NoError(v.ReadInConfig(), f.Name()) - var cfg Config - assert.NoError(v.Unmarshal(&cfg), f.Name()) - assert.False(cfg.PersistDerivedSRS, "%s must not opt into SRS writes", f.Name()) + if v.IsSet("persist_derived_srs") { + assert.True(v.GetBool("persist_derived_srs"), "%s must not opt out of SRS persistence", f.Name()) + } checked++ } assert.Greater(checked, 0, "no config files were checked") From 07b9cf8b77d1b23312fa46bc589c59fdc3bdd179 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Sat, 1 Aug 2026 21:00:48 +0200 Subject: [PATCH 15/31] feat(prover): warn loudly when a missing lagrange dump forces an in-memory derivation Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 00e38c1feb6..72c470fc15e 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -164,9 +164,16 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { // that a prove-time read can never mutate the SRS directory: an operator can // mount it read-only and still be sure the fast path is available, because // whether the dump exists was decided at provisioning time, not by whichever -// process happened to ask first. +// process happened to ask first. A miss is loud: hitting the derivation at +// prove time recurs on every start, so the warning names the one command that +// makes it stop. func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { - canonicalSRS, lagrangeSRS, _, err := store.resolveSRS(ccs) + canonicalSRS, lagrangeSRS, derived, err := store.resolveSRS(ccs) + if derived { + _, sizeLagrange := plonk.SRSSize(ccs) + logrus.Warnf("no loadable lagrange SRS dump of size %d for curve %s in %s — derived it in memory, which recurs on every start; run `prover setup` once to persist it", + sizeLagrange, fieldToCurve(ccs.Field()), store.rootDir) + } return canonicalSRS, lagrangeSRS, err } @@ -268,7 +275,9 @@ func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem) (kzg.SRS, kzg if sizeCanonical < sizeLagrange { panic("canonical SRS is smaller than lagrange SRS") } - logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d", sizeCanonical, sizeLagrange) + // Warn, not debug: this branch costs hours, and the operator deserves to + // know before the wait, not after. + logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d — this can take hours", sizeCanonical, sizeLagrange) lagrangeSRS, err := toLagrange(canonicalSRS, sizeLagrange) if err != nil { return nil, nil, false, err From 8db0a014de2eaeb9faf09bafb7397587d460fd48 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Sat, 1 Aug 2026 21:18:11 +0200 Subject: [PATCH 16/31] fix(prover): make the derived-SRS backfill cheap when done, fail fast when unwritable, and quiet at dummy sizes Signed-off-by: Coenie Beyers --- prover/circuits/srs_provider.go | 6 +- prover/circuits/srs_store.go | 68 +++++++++++++++----- prover/circuits/srs_store_test.go | 24 +++++-- prover/cmd/prover/cmd/setup.go | 8 +-- prover/cmd/prover/cmd/setup_backfill_test.go | 68 ++++++++++++++++++++ prover/config/config.go | 5 +- 6 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 prover/cmd/prover/cmd/setup_backfill_test.go diff --git a/prover/circuits/srs_provider.go b/prover/circuits/srs_provider.go index 338b0787d67..cca64baa4a4 100644 --- a/prover/circuits/srs_provider.go +++ b/prover/circuits/srs_provider.go @@ -19,8 +19,12 @@ type SRSProvider interface { // every prove path does, whereas writing into the SRS directory is a // provisioning action. Keeping the write off the interface every caller holds // is what stops it from happening implicitly. +// +// Without force, a dump of the right size already on disk counts as done and +// the call is cheap; force re-validates an existing dump in full and repairs +// it if it does not load, mirroring what --force means to `prover setup`. type LagrangePersister interface { - DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem) error + DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem, force bool) error } type UnsafeSRSProvider struct { diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 72c470fc15e..d8b8d948858 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -164,28 +164,63 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { // that a prove-time read can never mutate the SRS directory: an operator can // mount it read-only and still be sure the fast path is available, because // whether the dump exists was decided at provisioning time, not by whichever -// process happened to ask first. A miss is loud: hitting the derivation at -// prove time recurs on every start, so the warning names the one command that -// makes it stop. +// process happened to ask first. A miss at a real circuit size is loud: +// hitting the derivation at prove time recurs on every start, so the warning +// names the command and flag that make it stop. func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { canonicalSRS, lagrangeSRS, derived, err := store.resolveSRS(ccs) if derived { - _, sizeLagrange := plonk.SRSSize(ccs) - logrus.Warnf("no loadable lagrange SRS dump of size %d for curve %s in %s — derived it in memory, which recurs on every start; run `prover setup` once to persist it", - sizeLagrange, fieldToCurve(ccs.Field()), store.rootDir) + if _, sizeLagrange := plonk.SRSSize(ccs); sizeLagrange >= lagrangeSizeWarnThreshold { + logrus.Warnf("no loadable lagrange SRS dump of size %d for curve %s in %s — derived it in memory, which recurs on every start; run `prover setup` with persist_derived_srs on (the default) to persist it, adding --force if a dump was reported unloadable above", + sizeLagrange, fieldToCurve(ccs.Field()), store.rootDir) + } } return canonicalSRS, lagrangeSRS, err } +// lagrangeSizeWarnThreshold separates real circuit sizes from the tiny dummy +// circuits that pass through GetSRS during setup: below it a derivation costs +// milliseconds and is not worth an operator-facing warning. +const lagrangeSizeWarnThreshold = 1 << 20 + // DeriveAndPersistLagrange makes the Lagrange basis for ccs available on disk, -// so later runs load it instead of spending hours re-deriving it. It is a no-op -// when a loadable dump is already there, and it is the only path in the store -// that writes. +// so later runs load it instead of spending hours re-deriving it. It is the +// only path in the store that writes. +// +// Without force, it is as cheap as what there is to do: a dump of the right +// size already in the index counts as done without loading it (whether it +// loads is re-checked wherever it is read; repairing one that does not is +// force's job), and an unwritable directory is detected by a probe before the +// expensive derivation rather than after it. // // Best-effort is the caller's choice here rather than the store's: the error is // returned so an explicit provisioning step can report it, where a prove-time // read had to swallow it. -func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem) error { +func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem, force bool) error { + _, sizeLagrange := plonk.SRSSize(ccs) + curveID := fieldToCurve(ccs.Field()) + + if !force { + for _, entry := range store.entriesSnapshot(curveID) { + if !entry.isCanonical && entry.size == sizeLagrange { + // a dump of the right size is already on disk: done, without + // paying a multi-GiB load just to conclude there is nothing to + // write + return nil + } + } + } + + // probe writability before deriving: failing at the write would waste the + // hours-long derivation this call exists to save. The probe name matches + // the orphan-temp sweep pattern, so a crash leftover is reclaimed. + probe, err := os.CreateTemp(store.rootDir, "kzg_srs_probe.memdump.tmp") + if err != nil { + return fmt.Errorf("SRS directory %s is not writable, not deriving a basis that could not be persisted: %w", store.rootDir, err) + } + probe.Close() + os.Remove(probe.Name()) + _, lagrangeSRS, derived, err := store.resolveSRS(ccs) if err != nil { return err @@ -194,8 +229,7 @@ func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constra // a loadable dump is already on disk; nothing to publish return nil } - _, sizeLagrange := plonk.SRSSize(ccs) - return store.cacheLagrange(lagrangeSRS, sizeLagrange, fieldToCurve(ccs.Field())) + return store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID) } // resolveSRS loads the canonical SRS and either loads or derives the matching @@ -275,9 +309,13 @@ func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem) (kzg.SRS, kzg if sizeCanonical < sizeLagrange { panic("canonical SRS is smaller than lagrange SRS") } - // Warn, not debug: this branch costs hours, and the operator deserves to - // know before the wait, not after. - logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d — this can take hours", sizeCanonical, sizeLagrange) + if sizeLagrange >= lagrangeSizeWarnThreshold { + // Warn, not debug: at real circuit sizes this branch costs hours, and + // the operator deserves to know before the wait, not after. + logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d — this can take hours", sizeCanonical, sizeLagrange) + } else { + logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d", sizeCanonical, sizeLagrange) + } lagrangeSRS, err := toLagrange(canonicalSRS, sizeLagrange) if err != nil { return nil, nil, false, err diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index f71dbc07309..59e20340e9d 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -247,7 +247,7 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { // provisioning writes, under the derived tag and world-readable store, err := NewSRSStore(dir) assert.NoError(err) - assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs)) + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs, false)) info, err := os.Stat(cachedPath) assert.NoError(err, "DeriveAndPersistLagrange must publish the derived lagrange SRS") _, err = os.Stat(filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize))) @@ -261,7 +261,7 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { _, lagrangeSRS, err = fresh.GetSRS(context.TODO(), cs) assert.NoError(err) assert.NotNil(lagrangeSRS) - assert.NoError(fresh.DeriveAndPersistLagrange(context.TODO(), cs)) + assert.NoError(fresh.DeriveAndPersistLagrange(context.TODO(), cs, false)) after, err := os.Stat(cachedPath) assert.NoError(err) assert.Equal(info.ModTime(), after.ModTime(), "a cache hit must not rewrite the file") @@ -292,7 +292,16 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { _, lagrangeSRS, err := broken.GetSRS(context.TODO(), cs) assert.NoError(err, "an unloadable cached lagrange SRS must not fail GetSRS") assert.NotNil(lagrangeSRS) - assert.NoError(broken.DeriveAndPersistLagrange(context.TODO(), cs)) + + // without force, a right-size dump in the index counts as done — the + // cheap path must not load it, so it cannot know it is bad + derivedPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) + assert.NoError(broken.DeriveAndPersistLagrange(context.TODO(), cs, false)) + _, err = os.Stat(derivedPath) + assert.True(os.IsNotExist(err), "without force an indexed dump must be trusted, not repaired") + + // force validates in full and repairs beside the bad file + assert.NoError(broken.DeriveAndPersistLagrange(context.TODO(), cs, true)) // the operator's file is untouched, byte for byte stillBad, err := os.ReadFile(badPath) @@ -300,7 +309,6 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { assert.Equal(bad.content, stillBad, "the rejected dump must not be overwritten") // and a loadable, correct-size, same-setup dump exists beside it - derivedPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) reloaded := kzg.NewSRS(ecc.BN254) data, err := os.ReadFile(derivedPath) assert.NoError(err, "a derived dump must have been published") @@ -336,7 +344,8 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) assert.NoError(err) assert.NotNil(lagrangeSRS) - assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs)) + // force: only the full-validation path can tell a foreign dump apart + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs, true)) // the foreign dump is left alone; a matching basis is published beside it foreignAfter, err := os.ReadFile(lagrangePath) @@ -367,8 +376,9 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { assert.NotNil(lagrangeSRS) // provisioning returns the failure rather than swallowing it, so an - // operator running it deliberately finds out that nothing was written - assert.Error(roStore.DeriveAndPersistLagrange(context.TODO(), cs), + // operator running it deliberately finds out that nothing was written — + // and the writability probe reports it before deriving, not after + assert.Error(roStore.DeriveAndPersistLagrange(context.TODO(), cs, false), "provisioning must report that it could not write") _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) assert.True(os.IsNotExist(err), "nothing must be published to a read-only directory") diff --git a/prover/cmd/prover/cmd/setup.go b/prover/cmd/prover/cmd/setup.go index e2ac1f147dc..51b7fa0c9a0 100644 --- a/prover/cmd/prover/cmd/setup.go +++ b/prover/cmd/prover/cmd/setup.go @@ -206,11 +206,11 @@ func updateSetup(ctx context.Context, cfg *config.Config, force bool, // SRS directory, so a later prove-time read finds the Lagrange basis already // there instead of spending hours re-deriving it. It runs before the // skip-if-already-setup check so that re-running setup against current - // assets still backfills a missing dump, and it is a no-op when a loadable - // dump is on disk. Best-effort, since setup derives the basis in memory - // either way; persist_derived_srs = false turns it off. + // assets still backfills a missing dump; a dump already on disk makes this + // free, and --force re-validates it in full. Best-effort: a failed persist + // warns and setup carries on; persist_derived_srs = false turns it off. if persister, ok := srsProvider.(circuits.LagrangePersister); ok && cfg.PersistDerivedSRS { - if err := persister.DeriveAndPersistLagrange(ctx, ccs); err != nil { + if err := persister.DeriveAndPersistLagrange(ctx, ccs, force); err != nil { logrus.Warnf("could not persist derived lagrange SRS for %s (continuing): %v", circuit, err) } } diff --git a/prover/cmd/prover/cmd/setup_backfill_test.go b/prover/cmd/prover/cmd/setup_backfill_test.go new file mode 100644 index 00000000000..3c0d8a71174 --- /dev/null +++ b/prover/cmd/prover/cmd/setup_backfill_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/test/unsafekzg" + "github.com/consensys/linea-monorepo/prover/circuits" + "github.com/consensys/linea-monorepo/prover/circuits/dummy" + "github.com/consensys/linea-monorepo/prover/config" + "github.com/stretchr/testify/require" +) + +// TestUpdateSetupBackfillsMissingLagrangeDump pins the persist hook's placement +// and gate in updateSetup: the hook runs before the skip-if-already-setup +// check, so a setup re-run against current assets still backfills a missing +// derived dump, and persist_derived_srs = false keeps setup from writing at +// all. Nothing else exercises updateSetup, so a regression here only surfaces +// as an hours-long re-derivation at prover start. +func TestUpdateSetupBackfillsMissingLagrangeDump(t *testing.T) { + assert := require.New(t) + + builder := dummy.NewBuilder(circuits.MockCircuitIDEmulation, ecc.BN254.ScalarField()) + ccs, err := builder.Compile() + assert.NoError(err) + canonicalSize, lagrangeSize := plonk.SRSSize(ccs) + + cfg := &config.Config{AssetsDir: t.TempDir(), Version: "0.0.1", PersistDerivedSRS: true} + srsDir := cfg.PathForSRS() + assert.NoError(os.MkdirAll(srsDir, 0o700)) + + canonical, _, err := unsafekzg.NewSRS(ccs) + assert.NoError(err) + f, err := os.Create(filepath.Join(srsDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + assert.NoError(err) + assert.NoError(canonical.WriteDump(f)) + assert.NoError(f.Close()) + + derived := filepath.Join(srsDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize)) + newStore := func() *circuits.SRSStore { + s, err := circuits.NewSRSStore(srsDir) + assert.NoError(err) + return s + } + const circuitID = circuits.CircuitID("backfill-test") + + // a fresh setup publishes the assets and the derived dump + assert.NoError(updateSetup(context.TODO(), cfg, false, newStore(), circuitID, builder, nil)) + assert.FileExists(derived, "a fresh setup must persist the derived dump") + + // the headline behaviour: assets current (the skip path), dump missing — a + // fresh setup run must still backfill it + assert.NoError(os.Remove(derived)) + assert.NoError(updateSetup(context.TODO(), cfg, false, newStore(), circuitID, builder, nil)) + assert.FileExists(derived, "setup against current assets must backfill a missing dump") + + // and the gate: opting out keeps setup from writing + assert.NoError(os.Remove(derived)) + cfg.PersistDerivedSRS = false + assert.NoError(updateSetup(context.TODO(), cfg, false, newStore(), circuitID, builder, nil)) + _, err = os.Stat(derived) + assert.True(os.IsNotExist(err), "persist_derived_srs = false must keep setup from writing") +} diff --git a/prover/config/config.go b/prover/config/config.go index 85f74080ee1..dbf8cb89a62 100644 --- a/prover/config/config.go +++ b/prover/config/config.go @@ -153,8 +153,9 @@ type Config struct { // during setup — a deliberate provisioning action — never at prove time. Set // it to false for a deployment that wants its SRS directory strictly // immutable (read-only mount, snapshotted, or verified by hash); on such a - // volume the write degrades to a warning anyway. Nothing else in the prover - // ever writes there, whatever this is set to. + // volume a writability probe fails before any derivation is attempted and + // setup carries on with a warning. Nothing else in the prover ever writes + // there, whatever this is set to. PersistDerivedSRS bool `mapstructure:"persist_derived_srs"` Controller Controller From ee081b872266e06f6467b496db5911ef68edfbfa Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Sat, 1 Aug 2026 22:01:24 +0200 Subject: [PATCH 17/31] docs(prover): document derived-SRS persistence and its opt-out in the setup section Signed-off-by: Coenie Beyers --- prover/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/prover/README.md b/prover/README.md index 5b21528f14e..a7266f130f0 100644 --- a/prover/README.md +++ b/prover/README.md @@ -22,6 +22,14 @@ The repository counts 2 main binaries: The setup-generation (`make setup`) is used to generate the setup for all the types of provers. Execution, Decompression and Aggregation. By default, if the `--force` flag is not provided, the tool will compile the circuit and check if the destination dir already contains a setup that matches, skipping the CPU intensive phase of the actual plonk Setup if needed. +The setup also persists the Lagrange form of the SRS into the `kzgsrs` directory +(as `kzg_srs_lagrange___derived.memdump`) whenever it is missing, so +prover starts load it in seconds instead of re-deriving it for hours. It only ever +adds these `derived`-tagged files — ceremony files are never modified, and nothing +writes into the directory at prove time. Set `persist_derived_srs = false` in the +config to keep the SRS directory strictly read-only; `--force` additionally +re-validates an existing derived dump in full and repairs it if it does not load. + **Run** ```sh From 4c57b494839696f410c542637a286a1e0513cbb4 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Sat, 1 Aug 2026 22:06:42 +0200 Subject: [PATCH 18/31] docs(prover): tighten the derived-SRS persistence paragraph Signed-off-by: Coenie Beyers --- prover/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/prover/README.md b/prover/README.md index a7266f130f0..bdc45785dd1 100644 --- a/prover/README.md +++ b/prover/README.md @@ -24,11 +24,11 @@ By default, if the `--force` flag is not provided, the tool will compile the cir The setup also persists the Lagrange form of the SRS into the `kzgsrs` directory (as `kzg_srs_lagrange___derived.memdump`) whenever it is missing, so -prover starts load it in seconds instead of re-deriving it for hours. It only ever -adds these `derived`-tagged files — ceremony files are never modified, and nothing -writes into the directory at prove time. Set `persist_derived_srs = false` in the -config to keep the SRS directory strictly read-only; `--force` additionally -re-validates an existing derived dump in full and repairs it if it does not load. +prover starts load it in seconds instead of re-deriving it for hours. Ceremony +files are never modified, and nothing writes into the directory at prove time. +Set `persist_derived_srs = false` in the config to keep the SRS directory +strictly read-only; `--force` additionally re-validates an existing derived dump +in full and repairs it if it does not load. **Run** From ee1c8621d8a3fc9827c9def96ab1d0cc9ba2c470 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 16:34:33 +0200 Subject: [PATCH 19/31] fix(prover): sweep orphaned srs temps from the setup write path, never at prove time Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 46 +++++++++++++++++++++---------- prover/circuits/srs_store_test.go | 15 ++++++++-- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index d8b8d948858..0e7166e4a17 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -59,8 +59,8 @@ type fsEntry struct { const derivedSourceTag = "derived" // orphanTempMaxAge is how long a temp file's last write must lie in the past -// before store construction deletes it; a live writer keeps refreshing its -// temp's mtime while streaming the dump. +// before provisioning deletes it; a live writer keeps refreshing its temp's +// mtime while streaming the dump. const orphanTempMaxAge = time.Hour // curveFileNames maps a curve ID to the token naming it in SRS file names. @@ -113,19 +113,6 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { fileName := entry.Name() matches := srsRegexp.FindStringSubmatch(fileName) if matches == nil { - // a crash mid-write (e.g. an OOM-kill during a multi-GiB dump) - // orphans a temp file that nothing indexes or reclaims; delete it - // once its last write is more than an hour old — a live writer's - // temp is always newer than that - if strings.HasPrefix(fileName, "kzg_srs_") && strings.Contains(fileName, ".memdump.tmp") { - if info, err := entry.Info(); err == nil && time.Since(info.ModTime()) > orphanTempMaxAge { - if err := os.Remove(filepath.Join(rootDir, fileName)); err != nil { - logrus.Warnf("could not remove orphaned srs temp file %s: %v", fileName, err) - } else { - logrus.Infof("removed orphaned srs temp file %s", fileName) - } - } - } continue } @@ -197,6 +184,10 @@ const lagrangeSizeWarnThreshold = 1 << 20 // returned so an explicit provisioning step can report it, where a prove-time // read had to swallow it. func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem, force bool) error { + // reclaim crash leftovers before the cheap-skip: after a crashed --force + // re-write a valid dump still exists, so no later step would ever run + store.sweepOrphanTemps() + _, sizeLagrange := plonk.SRSSize(ccs) curveID := fieldToCurve(ccs.Field()) @@ -232,6 +223,31 @@ func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constra return store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID) } +// sweepOrphanTemps deletes temp files orphaned by a crash mid-write (e.g. an +// OOM-kill during a multi-GiB dump) once their last write is more than an +// hour old — a live writer's temp is always newer than that. Only the +// provisioning path calls it: constructing or reading the store never mutates +// the directory. +func (store *SRSStore) sweepOrphanTemps() { + dir, err := os.ReadDir(store.rootDir) + if err != nil { + return + } + for _, entry := range dir { + fileName := entry.Name() + if entry.IsDir() || !strings.HasPrefix(fileName, "kzg_srs_") || !strings.Contains(fileName, ".memdump.tmp") { + continue + } + if info, err := entry.Info(); err == nil && time.Since(info.ModTime()) > orphanTempMaxAge { + if err := os.Remove(filepath.Join(store.rootDir, fileName)); err != nil { + logrus.Warnf("could not remove orphaned srs temp file %s: %v", fileName, err) + } else { + logrus.Infof("removed orphaned srs temp file %s", fileName) + } + } + } +} + // resolveSRS loads the canonical SRS and either loads or derives the matching // Lagrange basis, reporting whether it had to derive. It never writes. // diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 59e20340e9d..c1da6b67008 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -196,8 +196,13 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { t.Run("sweeps_only_aged_orphan_temps", func(t *testing.T) { assert := require.New(t) dir := t.TempDir() - canonical := newTestCanonicalSRS(t, ecc.BN254, 16) - dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + + cs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &circuit{make([]frontend.Variable, 1)}) + assert.NoError(err) + canonicalSize, _ := plonk.SRSSize(cs) + canonical, _, err := unsafekzg.NewSRS(cs) + assert.NoError(err) + dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aztec.memdump", canonicalSize))) // a crash-orphaned temp (old) and a concurrent writer's temp (fresh) aged := filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump.tmp111") @@ -208,13 +213,17 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { store, err := NewSRSStore(dir) assert.NoError(err) + _, err = os.Stat(aged) + assert.NoError(err, "constructing the store must not mutate the directory") + // provisioning sweeps the aged orphan and spares the live writer's temp + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs, false)) _, err = os.Stat(aged) assert.True(os.IsNotExist(err), "an aged orphan temp must be swept") _, err = os.Stat(fresh) assert.NoError(err, "a fresh temp must be spared") for _, entry := range store.entriesSnapshot(ecc.BN254) { - assert.True(entry.isCanonical, "temp files must never be indexed") + assert.NotContains(entry.path, ".memdump.tmp", "temp files must never be indexed") } }) } From 1a52d56c2a8430bedda6010a3898a59f1fd4772f Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 16:41:14 +0200 Subject: [PATCH 20/31] test(prover): pin that a failed derived-SRS persist warns without failing setup Signed-off-by: Coenie Beyers --- prover/cmd/prover/cmd/setup_backfill_test.go | 30 ++++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/prover/cmd/prover/cmd/setup_backfill_test.go b/prover/cmd/prover/cmd/setup_backfill_test.go index 3c0d8a71174..5c0b69a2fad 100644 --- a/prover/cmd/prover/cmd/setup_backfill_test.go +++ b/prover/cmd/prover/cmd/setup_backfill_test.go @@ -2,13 +2,16 @@ package cmd import ( "context" + "errors" "fmt" "os" "path/filepath" "testing" "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark-crypto/kzg" "github.com/consensys/gnark/backend/plonk" + "github.com/consensys/gnark/constraint" "github.com/consensys/gnark/test/unsafekzg" "github.com/consensys/linea-monorepo/prover/circuits" "github.com/consensys/linea-monorepo/prover/circuits/dummy" @@ -19,9 +22,10 @@ import ( // TestUpdateSetupBackfillsMissingLagrangeDump pins the persist hook's placement // and gate in updateSetup: the hook runs before the skip-if-already-setup // check, so a setup re-run against current assets still backfills a missing -// derived dump, and persist_derived_srs = false keeps setup from writing at -// all. Nothing else exercises updateSetup, so a regression here only surfaces -// as an hours-long re-derivation at prover start. +// derived dump, persist_derived_srs = false keeps setup from writing at all, +// and a failed persist warns without failing setup. Nothing else exercises +// updateSetup, so a regression here only surfaces as an hours-long +// re-derivation at prover start. func TestUpdateSetupBackfillsMissingLagrangeDump(t *testing.T) { assert := require.New(t) @@ -65,4 +69,24 @@ func TestUpdateSetupBackfillsMissingLagrangeDump(t *testing.T) { assert.NoError(updateSetup(context.TODO(), cfg, false, newStore(), circuitID, builder, nil)) _, err = os.Stat(derived) assert.True(os.IsNotExist(err), "persist_derived_srs = false must keep setup from writing") + + // a failed persist must warn and carry on, as the config doc promises + // immutable-SRS deployments — with assets current, a returned error can + // only be the hook's warn-and-continue regressing + cfg.PersistDerivedSRS = true + assert.NoError(updateSetup(context.TODO(), cfg, false, failingPersister{newStore()}, circuitID, builder, nil), + "a persist failure must never fail setup") + assert.NoFileExists(derived) +} + +// a persister whose provisioning always fails: setup must log a warning and +// carry on, never fail, when the dump cannot be written +type failingPersister struct{ inner *circuits.SRSStore } + +func (p failingPersister) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { + return p.inner.GetSRS(ctx, ccs) +} + +func (p failingPersister) DeriveAndPersistLagrange(context.Context, constraint.ConstraintSystem, bool) error { + return errors.New("srs directory unwritable") } From f6dab906f4207f4fe53be5cbeec7ad92a84e14d1 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 16:55:33 +0200 Subject: [PATCH 21/31] fix(prover): ignore a canonical SRS claiming the derived tag instead of trusting it Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 10 ++++++++++ prover/circuits/srs_store_test.go | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 0e7166e4a17..ede60f1d1c4 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -56,6 +56,9 @@ type fsEntry struct { // the only signal they get. Nothing in the store validates that a file tagged // "aztec" descends from that ceremony, so a tag written by code is a // provenance claim the code cannot support: never write one. +// +// The tag is only meaningful on a lagrange basis: the store never computes +// canonical material, so a canonical name claiming it is ignored. const derivedSourceTag = "derived" // orphanTempMaxAge is how long a temp file's last write must lie in the past @@ -119,6 +122,13 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { isCanonical := matches[2] == "canonical" size, _ := strconv.Atoi(matches[3]) source := matches[5] + // only a lagrange basis can be locally computed: a canonical dump + // carrying the derived tag is not a name anything writes, so treat + // it as noise rather than trusted, fatal-if-bad ceremony material + if isCanonical && source == derivedSourceTag { + logrus.Warnf("ignoring %s: a canonical SRS cannot carry the %q tag", fileName, derivedSourceTag) + continue + } curveID, ok := curveIDsByFileName[matches[4]] if !ok { return nil, errors.New("curve not supported") diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index c1da6b67008..d7224f30b14 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -397,7 +397,8 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { // TestSRSStore_DerivedDumpsNeverClaimACeremony pins the provenance rule: a // locally computed basis is published under derivedSourceTag whatever ceremony // the canonical SRS it came from was tagged with, and is loadable again from -// that name. +// that name. The mirror also holds: a canonical dump claiming the derived tag +// is ignored, never indexed as ceremony material. func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { for _, ceremony := range []string{"aleo", "aztec", "celo"} { t.Run("canonical_"+ceremony, func(t *testing.T) { @@ -430,6 +431,23 @@ func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { assert.True(indexed, "a derived dump must be indexed on the next construction") }) } + + t.Run("canonical_never_carries_derived", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + + canonical := newTestCanonicalSRS(t, ecc.BN254, 16) + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_aztec.memdump")) + // a name nothing writes: a canonical claiming to be locally computed + // must be ignored, not indexed as trusted ceremony material + dumpToFile(t, canonical, filepath.Join(dir, "kzg_srs_canonical_16_bn254_derived.memdump")) + + store, err := NewSRSStore(dir) + assert.NoError(err) + entries := store.entriesSnapshot(ecc.BN254) + assert.Len(entries, 1, "a derived-tagged canonical must not be indexed") + assert.Contains(entries[0].path, "aztec") + }) } // dirNames lists the entry names in dir, sorted. From 656de86b69dab8044de8fb804029edda199147a3 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:02:01 +0200 Subject: [PATCH 22/31] fix(prover): name the persist remedy before the derivation wait, not after it Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index ede60f1d1c4..338f0f56fbe 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -165,13 +165,7 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { // hitting the derivation at prove time recurs on every start, so the warning // names the command and flag that make it stop. func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, error) { - canonicalSRS, lagrangeSRS, derived, err := store.resolveSRS(ccs) - if derived { - if _, sizeLagrange := plonk.SRSSize(ccs); sizeLagrange >= lagrangeSizeWarnThreshold { - logrus.Warnf("no loadable lagrange SRS dump of size %d for curve %s in %s — derived it in memory, which recurs on every start; run `prover setup` with persist_derived_srs on (the default) to persist it, adding --force if a dump was reported unloadable above", - sizeLagrange, fieldToCurve(ccs.Field()), store.rootDir) - } - } + canonicalSRS, lagrangeSRS, _, err := store.resolveSRS(ccs, false) return canonicalSRS, lagrangeSRS, err } @@ -222,7 +216,7 @@ func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constra probe.Close() os.Remove(probe.Name()) - _, lagrangeSRS, derived, err := store.resolveSRS(ccs) + _, lagrangeSRS, derived, err := store.resolveSRS(ccs, true) if err != nil { return err } @@ -260,11 +254,14 @@ func (store *SRSStore) sweepOrphanTemps() { // resolveSRS loads the canonical SRS and either loads or derives the matching // Lagrange basis, reporting whether it had to derive. It never writes. +// provisioning tells it who is asking, for the derivation warning's sake: a +// prove-time caller is told the setup command that makes the derivation stop +// recurring, a provisioning caller is already running it. // // Concurrent callers requesting the same missing size each derive independently // (the store is race-safe but does not deduplicate the work); every in-repo // caller is sequential today. -func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem) (kzg.SRS, kzg.SRS, bool, error) { +func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem, provisioning bool) (kzg.SRS, kzg.SRS, bool, error) { sizeCanonical, sizeLagrange := plonk.SRSSize(ccs) curveID := fieldToCurve(ccs.Field()) @@ -336,9 +333,18 @@ func (store *SRSStore) resolveSRS(ccs constraint.ConstraintSystem) (kzg.SRS, kzg panic("canonical SRS is smaller than lagrange SRS") } if sizeLagrange >= lagrangeSizeWarnThreshold { - // Warn, not debug: at real circuit sizes this branch costs hours, and - // the operator deserves to know before the wait, not after. - logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d — this can take hours", sizeCanonical, sizeLagrange) + // warn, not debug: at real circuit sizes this branch costs hours, and + // the operator deserves to know before the wait, not after — remedy + // included, because a killed process never reaches a post-hoc hint + if provisioning { + logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d — this can take hours", + sizeCanonical, sizeLagrange) + } else { + logrus.Warnf("computing lagrange SRS from canonical SRS %d -> %d in memory — this can take hours "+ + "and recurs on every start; run `prover setup` with persist_derived_srs on (the default) to "+ + "persist it, adding --force if a dump was reported unloadable above", + sizeCanonical, sizeLagrange) + } } else { logrus.Debugf("computing lagrange SRS from canonical SRS %d -> %d", sizeCanonical, sizeLagrange) } From d761d016eb361a583e3594905df17514a5f8bb5e Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:10:11 +0200 Subject: [PATCH 23/31] test(prover): pin the derivation warning's gate, remedy text, and silence after setup Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 6 ++- prover/circuits/srs_store_test.go | 72 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 338f0f56fbe..40f66a7909e 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -171,8 +171,10 @@ func (store *SRSStore) GetSRS(ctx context.Context, ccs constraint.ConstraintSyst // lagrangeSizeWarnThreshold separates real circuit sizes from the tiny dummy // circuits that pass through GetSRS during setup: below it a derivation costs -// milliseconds and is not worth an operator-facing warning. -const lagrangeSizeWarnThreshold = 1 << 20 +// milliseconds and is not worth an operator-facing warning. A var rather than +// a const only so tests can lower it to reach the warning path without a +// million-point derivation; production code never writes it. +var lagrangeSizeWarnThreshold = 1 << 20 // DeriveAndPersistLagrange makes the Lagrange basis for ccs available on disk, // so later runs load it instead of spending hours re-deriving it. It is the diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index d7224f30b14..ac125ab3e4f 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -18,6 +18,8 @@ import ( "github.com/consensys/gnark/frontend" "github.com/consensys/gnark/frontend/cs/scs" "github.com/consensys/gnark/test/unsafekzg" + "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/require" kzg377 "github.com/consensys/gnark-crypto/ecc/bls12-377/kzg" @@ -450,6 +452,76 @@ func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { }) } +// TestSRSStore_DerivationWarning pins the miss warning's gate and content: a +// dummy-size derivation stays quiet, a real-size prove-time miss warns once +// and names the remedy, provisioning is not told to run the command it +// already is, and once the dump is persisted the same read is silent. +// It lowers lagrangeSizeWarnThreshold and captures the global logger, so it +// must not run in parallel. +func TestSRSStore_DerivationWarning(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + + cs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &circuit{make([]frontend.Variable, 1)}) + assert.NoError(err) + canonicalSize, _ := plonk.SRSSize(cs) + canonical, _, err := unsafekzg.NewSRS(cs) + assert.NoError(err) + dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + + store, err := NewSRSStore(dir) + assert.NoError(err) + hook := logtest.NewGlobal() + defer hook.Reset() + + // below the threshold a derivation is routine: no operator-facing warning + _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + for _, e := range hook.AllEntries() { + assert.Greater(e.Level, logrus.WarnLevel, "a dummy-size derivation must not warn: %s", e.Message) + } + + oldThreshold := lagrangeSizeWarnThreshold + lagrangeSizeWarnThreshold = 1 + t.Cleanup(func() { lagrangeSizeWarnThreshold = oldThreshold }) + + // at a real size a prove-time miss is loud, once, and names the remedy + hook.Reset() + _, _, err = store.GetSRS(context.TODO(), cs) + assert.NoError(err) + warnings := 0 + for _, e := range hook.AllEntries() { + if e.Level == logrus.WarnLevel { + warnings++ + assert.Contains(e.Message, "prover setup", "the warning must name the command that stops the recurrence") + assert.Contains(e.Message, "persist_derived_srs", "the warning must name the flag that gates the persist") + } + } + assert.Equal(1, warnings, "a real-size prove-time miss must warn exactly once") + + // provisioning announces the wait but is not told to run itself + hook.Reset() + assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs, false)) + warned := false + for _, e := range hook.AllEntries() { + if e.Level == logrus.WarnLevel { + warned = true + assert.NotContains(e.Message, "prover setup", "provisioning must not be told to run the command it already is") + } + } + assert.True(warned, "a real-size provisioning derivation must still announce the wait") + + // the named remedy works: with the dump persisted, the same read is silent + hook.Reset() + _, lagrangeSRS, err = store.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + for _, e := range hook.AllEntries() { + assert.Greater(e.Level, logrus.WarnLevel, "after setup persisted the dump a read must be quiet: %s", e.Message) + } +} + // dirNames lists the entry names in dir, sorted. func dirNames(t *testing.T, dir string) []string { t.Helper() From 46ef485912f3592227bac94341de8ab89effbab4 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:12:06 +0200 Subject: [PATCH 24/31] test(prover): pin that the writability probe reports before the derivation, not after Signed-off-by: Coenie Beyers --- prover/circuits/srs_store_test.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index ac125ab3e4f..da855762303 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -206,12 +206,16 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) dumpToFile(t, canonical, filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aztec.memdump", canonicalSize))) - // a crash-orphaned temp (old) and a concurrent writer's temp (fresh) + // a crash-orphaned temp (old), a crashed probe's leftover (old) and a + // concurrent writer's temp (fresh) aged := filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump.tmp111") + agedProbe := filepath.Join(dir, "kzg_srs_probe.memdump.tmp123") fresh := filepath.Join(dir, "kzg_srs_lagrange_8_bn254_aztec.memdump.tmp222") assert.NoError(os.WriteFile(aged, []byte("dead"), 0o600)) + assert.NoError(os.WriteFile(agedProbe, []byte("dead"), 0o600)) assert.NoError(os.WriteFile(fresh, []byte("live"), 0o600)) assert.NoError(os.Chtimes(aged, time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour))) + assert.NoError(os.Chtimes(agedProbe, time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour))) store, err := NewSRSStore(dir) assert.NoError(err) @@ -222,6 +226,8 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(store.DeriveAndPersistLagrange(context.TODO(), cs, false)) _, err = os.Stat(aged) assert.True(os.IsNotExist(err), "an aged orphan temp must be swept") + _, err = os.Stat(agedProbe) + assert.True(os.IsNotExist(err), "a crashed probe's leftover must be swept") _, err = os.Stat(fresh) assert.NoError(err, "a fresh temp must be spared") for _, entry := range store.entriesSnapshot(ecc.BN254) { @@ -370,7 +376,7 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { assertSameVk(t, canonical, reloaded) }) - t.Run("read_only_store_dir_is_best_effort", func(t *testing.T) { + t.Run("read_only_dir_reads_fine_but_provisioning_reports", func(t *testing.T) { assert := require.New(t) if os.Geteuid() == 0 { t.Skip("directory permissions are not enforced for root") @@ -388,9 +394,10 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { // provisioning returns the failure rather than swallowing it, so an // operator running it deliberately finds out that nothing was written — - // and the writability probe reports it before deriving, not after - assert.Error(roStore.DeriveAndPersistLagrange(context.TODO(), cs, false), - "provisioning must report that it could not write") + // and the wording pins that the probe reported it before deriving, + // not cacheLagrange after + assert.ErrorContains(roStore.DeriveAndPersistLagrange(context.TODO(), cs, false), + "not deriving a basis", "provisioning must report that it could not write, from the probe") _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) assert.True(os.IsNotExist(err), "nothing must be published to a read-only directory") }) From b60c1ed4404011b6059201863ac9f50a904f9432 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:13:27 +0200 Subject: [PATCH 25/31] test(prover): pin that canonical SRS load failures stay fatal Signed-off-by: Coenie Beyers --- prover/circuits/srs_store_test.go | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index da855762303..1ffbe4b114b 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -529,6 +529,38 @@ func TestSRSStore_DerivationWarning(t *testing.T) { } } +// TestSRSStore_CanonicalLoadFailuresStayFatal pins the boundary of the +// lenient-load behaviour: a lagrange dump that fails to load is re-derived, +// but ceremony (canonical) material is not reconstructible, so a corrupt or +// missing canonical dump must fail GetSRS, never fall back. +func TestSRSStore_CanonicalLoadFailuresStayFatal(t *testing.T) { + assert := require.New(t) + + cs, err := frontend.Compile(ecc.BN254.ScalarField(), scs.NewBuilder, &circuit{make([]frontend.Variable, 1)}) + assert.NoError(err) + canonicalSize, _ := plonk.SRSSize(cs) + + t.Run("corrupt", func(t *testing.T) { + assert := require.New(t) + dir := t.TempDir() + assert.NoError(os.WriteFile( + filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize)), + []byte("garbage"), 0o644)) + store, err := NewSRSStore(dir) + assert.NoError(err) + _, _, err = store.GetSRS(context.TODO(), cs) + assert.Error(err, "a corrupt canonical dump must fail GetSRS, not fall back to deriving") + }) + + t.Run("missing", func(t *testing.T) { + assert := require.New(t) + store, err := NewSRSStore(t.TempDir()) + assert.NoError(err) + _, _, err = store.GetSRS(context.TODO(), cs) + assert.ErrorContains(err, "could not find canonical SRS") + }) +} + // dirNames lists the entry names in dir, sorted. func dirNames(t *testing.T, dir string) []string { t.Helper() From 1d3335f4f2a0e18dd4a0fe25db8c629f6e7d22cf Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:15:59 +0200 Subject: [PATCH 26/31] refactor(prover): rename cacheLagrange to persistLagrange and unify its cleanup Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 24 ++++++++++++++---------- prover/circuits/srs_store_test.go | 16 ++++++++-------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 40f66a7909e..8246698bb23 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -226,7 +226,7 @@ func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constra // a loadable dump is already on disk; nothing to publish return nil } - return store.cacheLagrange(lagrangeSRS, sizeLagrange, curveID) + return store.persistLagrange(lagrangeSRS, sizeLagrange, curveID) } // sweepOrphanTemps deletes temp files orphaned by a crash mid-write (e.g. an @@ -382,7 +382,7 @@ func (store *SRSStore) register(curveID ecc.ID, newEntry fsEntry) { }) } -// cacheLagrange writes a derived Lagrange SRS into the store's directory using +// persistLagrange writes a derived Lagrange SRS into the store's directory using // the naming scheme NewSRSStore parses, and registers it in the index. The dump // is fsync'd and renamed into place atomically, so a torn write cannot appear // under a trusted name. On load, framing, size, setup-identity (Vk) and @@ -392,7 +392,7 @@ func (store *SRSStore) register(curveID ecc.ID, newEntry fsEntry) { // The published name always carries derivedSourceTag, so the only file this can // ever replace is one it wrote itself: a ceremony dump on the same path is // impossible by construction, and a stale derived dump is safe to supersede. -func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID) error { +func (store *SRSStore) persistLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curveID ecc.ID) error { curveName, ok := curveFileNames[curveID] if !ok { return fmt.Errorf("curve not supported: %s", curveID) @@ -405,30 +405,34 @@ func (store *SRSStore) cacheLagrange(lagrangeSRS kzg.SRS, sizeLagrange int, curv if err != nil { return err } + // one cleanup for every failure path: until the rename publishes the dump, + // returning is what removes the temp + published := false + defer func() { + if !published { + f.Close() + os.Remove(f.Name()) + } + }() + if err := lagrangeSRS.WriteDump(f); err != nil { - f.Close() - os.Remove(f.Name()) return fmt.Errorf("writing srs dump: %w", err) } if err := f.Sync(); err != nil { - f.Close() - os.Remove(f.Name()) return err } if err := f.Close(); err != nil { - os.Remove(f.Name()) return err } // ceremony files in the store are world-readable; match them so the cache // stays loadable when a later run uses a different uid if err := os.Chmod(f.Name(), 0o644); err != nil { - os.Remove(f.Name()) return err } if err := os.Rename(f.Name(), finalPath); err != nil { - os.Remove(f.Name()) return err } + published = true syncDir(store.rootDir) store.register(curveID, fsEntry{isCanonical: false, size: sizeLagrange, path: finalPath, source: derivedSourceTag}) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 1ffbe4b114b..a792760658f 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -104,7 +104,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { lagrange, err := toLagrange(canonical, 8) assert.NoError(err) - assert.NoError(store.cacheLagrange(lagrange, 8, tc.curveID)) + assert.NoError(store.persistLagrange(lagrange, 8, tc.curveID)) // a fresh store must pick the cached file up and be able to load it fresh, err := NewSRSStore(dir) @@ -143,13 +143,13 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { go func() { defer wg.Done() _ = store.entriesSnapshot(ecc.BN254) - errs <- store.cacheLagrange(lagrange, 8, ecc.BN254) + errs <- store.persistLagrange(lagrange, 8, ecc.BN254) }() } wg.Wait() close(errs) for err := range errs { - assert.NoError(err, "concurrent cacheLagrange calls must all succeed") + assert.NoError(err, "concurrent persistLagrange calls must all succeed") } count := 0 for _, entry := range store.entriesSnapshot(ecc.BN254) { @@ -169,11 +169,11 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { assert.NoError(err) lagrange, err := toLagrange(canonical, 8) assert.NoError(err) - assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254)) + assert.NoError(store.persistLagrange(lagrange, 8, ecc.BN254)) leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) assert.NoError(err) - assert.Empty(leftovers, "cacheLagrange must not leave temp files behind") + assert.Empty(leftovers, "persistLagrange must not leave temp files behind") }) t.Run("failed_publish_cleans_up_its_temp", func(t *testing.T) { @@ -188,7 +188,7 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { // a directory squatting on the final name makes the rename fail assert.NoError(os.Mkdir(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_derived.memdump"), 0o700)) - assert.Error(store.cacheLagrange(lagrange, 8, ecc.BN254), "publish must fail when the final name is taken by a directory") + assert.Error(store.persistLagrange(lagrange, 8, ecc.BN254), "publish must fail when the final name is taken by a directory") leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) assert.NoError(err) @@ -395,7 +395,7 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { // provisioning returns the failure rather than swallowing it, so an // operator running it deliberately finds out that nothing was written — // and the wording pins that the probe reported it before deriving, - // not cacheLagrange after + // not persistLagrange after assert.ErrorContains(roStore.DeriveAndPersistLagrange(context.TODO(), cs, false), "not deriving a basis", "provisioning must report that it could not write, from the probe") _, err = os.Stat(filepath.Join(roDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) @@ -421,7 +421,7 @@ func TestSRSStore_DerivedDumpsNeverClaimACeremony(t *testing.T) { assert.NoError(err) lagrange, err := toLagrange(canonical, 8) assert.NoError(err) - assert.NoError(store.cacheLagrange(lagrange, 8, ecc.BN254)) + assert.NoError(store.persistLagrange(lagrange, 8, ecc.BN254)) assert.FileExists(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_derived.memdump")) _, err = os.Stat(filepath.Join(dir, fmt.Sprintf("kzg_srs_lagrange_8_bn254_%s.memdump", ceremony))) From 2bb5c4d936ebb9595663b9f5a26bfae1410712fc Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:16:30 +0200 Subject: [PATCH 27/31] refactor(prover): build the srs filename pattern's curve list from curveFileNames Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 8246698bb23..58a42cd4bf4 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -101,9 +101,17 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { srsStore.entries[ecc.BN254] = []fsEntry{} srsStore.entries[ecc.BW6_761] = []fsEntry{} - // the trailing group is the provenance tag: one of the three ceremonies the - // store may be seeded from, or derivedSourceTag for a locally computed basis - srsRegexp := regexp.MustCompile(`^(kzg_srs)_(canonical|lagrange)_(\d+)_(bls12377|bn254|bw6761)_(aleo|aztec|celo|` + derivedSourceTag + `)\.memdump$`) + // the curve alternation comes from curveFileNames, the same map the write + // path names files with, so a new curve is added in one place; the trailing + // group is the provenance tag: one of the three ceremonies the store may be + // seeded from, or derivedSourceTag for a locally computed basis + curveTokens := make([]string, 0, len(curveFileNames)) + for _, name := range curveFileNames { + curveTokens = append(curveTokens, name) + } + sort.Strings(curveTokens) + srsRegexp := regexp.MustCompile(`^(kzg_srs)_(canonical|lagrange)_(\d+)_(` + + strings.Join(curveTokens, "|") + `)_(aleo|aztec|celo|` + derivedSourceTag + `)\.memdump$`) for _, entry := range dir { if entry.IsDir() { From bbcd83d3c973a21369eabe6b8d376e4d0f6a6cd9 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:18:27 +0200 Subject: [PATCH 28/31] chore(prover): tidy docs, name the WriterstoEqual regression test, wrap over-limit lines Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 7 +++++-- prover/circuits/srs_store_test.go | 9 ++++++--- prover/config/config_test.go | 1 + 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 58a42cd4bf4..75e28fdbeb2 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -197,7 +197,9 @@ var lagrangeSizeWarnThreshold = 1 << 20 // Best-effort is the caller's choice here rather than the store's: the error is // returned so an explicit provisioning step can report it, where a prove-time // read had to swallow it. -func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constraint.ConstraintSystem, force bool) error { +func (store *SRSStore) DeriveAndPersistLagrange( + ctx context.Context, ccs constraint.ConstraintSystem, force bool, +) error { // reclaim crash leftovers before the cheap-skip: after a crashed --force // re-write a valid dump still exists, so no later step would ever run store.sweepOrphanTemps() @@ -221,7 +223,8 @@ func (store *SRSStore) DeriveAndPersistLagrange(ctx context.Context, ccs constra // the orphan-temp sweep pattern, so a crash leftover is reclaimed. probe, err := os.CreateTemp(store.rootDir, "kzg_srs_probe.memdump.tmp") if err != nil { - return fmt.Errorf("SRS directory %s is not writable, not deriving a basis that could not be persisted: %w", store.rootDir, err) + return fmt.Errorf("SRS directory %s is not writable, not deriving a basis that could not be persisted: %w", + store.rootDir, err) } probe.Close() os.Remove(probe.Name()) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index a792760658f..3798d727d68 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -188,7 +188,8 @@ func TestSRSStore_PersistsDerivedLagrange(t *testing.T) { // a directory squatting on the final name makes the rename fail assert.NoError(os.Mkdir(filepath.Join(dir, "kzg_srs_lagrange_8_bn254_derived.memdump"), 0o700)) - assert.Error(store.persistLagrange(lagrange, 8, ecc.BN254), "publish must fail when the final name is taken by a directory") + assert.Error(store.persistLagrange(lagrange, 8, ecc.BN254), + "publish must fail when the final name is taken by a directory") leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) assert.NoError(err) @@ -300,7 +301,8 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { // warn, re-derive, and publish under the derived tag while leaving // the ceremony-tagged file it did not write completely alone subDir := t.TempDir() - dumpToFile(t, canonical, filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) + dumpToFile(t, canonical, + filepath.Join(subDir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize))) badPath := filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_aleo.memdump", lagrangeSize)) assert.NoError(os.WriteFile(badPath, bad.content, 0o600)) @@ -370,7 +372,8 @@ func TestSRSStore_DeriveAndPersistLagrange(t *testing.T) { assert.Equal(foreignBefore, foreignAfter, "a dump from another setup must not be overwritten") reloaded := kzg.NewSRS(ecc.BN254) - data, err := os.ReadFile(filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) + data, err := os.ReadFile( + filepath.Join(subDir, fmt.Sprintf("kzg_srs_lagrange_%d_bn254_derived.memdump", lagrangeSize))) assert.NoError(err, "a derived dump matching this canonical must have been published") assert.NoError(reloaded.ReadDump(bytes.NewReader(data))) assertSameVk(t, canonical, reloaded) diff --git a/prover/config/config_test.go b/prover/config/config_test.go index 6487b8098ce..35c5ce69514 100644 --- a/prover/config/config_test.go +++ b/prover/config/config_test.go @@ -84,6 +84,7 @@ func TestPersistDerivedSRSDefaultsOn(t *testing.T) { // a missing lagrange dump is otherwise re-derived silently for hours on // every prover start, so persistence at setup is what an operator gets by // omission; the default is applied by the real loading path + // the smallest config the unchecked loading path accepts: the layer2 // addresses are parsed unconditionally, even without validation minimal := `assets_dir = "/tmp/assets" From d00f15cf15f1260f26244e419b7aef652fd278cb Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:34:08 +0200 Subject: [PATCH 29/31] test(prover): pin that a corrupt canonical fails as a load error, not not-found Signed-off-by: Coenie Beyers --- prover/circuits/srs_store.go | 4 +++- prover/circuits/srs_store_test.go | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 75e28fdbeb2..5f981351527 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -186,7 +186,9 @@ var lagrangeSizeWarnThreshold = 1 << 20 // DeriveAndPersistLagrange makes the Lagrange basis for ccs available on disk, // so later runs load it instead of spending hours re-deriving it. It is the -// only path in the store that writes. +// only path in the store that writes. Its first act, even when there is +// nothing to persist, is reclaiming temp files orphaned by a crashed earlier +// run. // // Without force, it is as cheap as what there is to do: a dump of the right // size already in the index counts as done without loading it (whether it diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 3798d727d68..d5011b62ab1 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -553,6 +553,10 @@ func TestSRSStore_CanonicalLoadFailuresStayFatal(t *testing.T) { assert.NoError(err) _, _, err = store.GetSRS(context.TODO(), cs) assert.Error(err, "a corrupt canonical dump must fail GetSRS, not fall back to deriving") + // distinguish the load error from the not-found fallback: a lenient + // skip-and-continue regression would surface as the latter + assert.NotContains(err.Error(), "could not find canonical SRS", + "the failure must be the load error itself") }) t.Run("missing", func(t *testing.T) { From 882ed1f48e927dce1d281a8d3e360b4c8eaa9c7e Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Tue, 4 Aug 2026 17:37:23 +0200 Subject: [PATCH 30/31] chore(prover): write the corrupt-canonical fixture 0600 to satisfy gosec Signed-off-by: Coenie Beyers --- prover/circuits/srs_store_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index d5011b62ab1..666ae314468 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -548,7 +548,7 @@ func TestSRSStore_CanonicalLoadFailuresStayFatal(t *testing.T) { dir := t.TempDir() assert.NoError(os.WriteFile( filepath.Join(dir, fmt.Sprintf("kzg_srs_canonical_%d_bn254_aleo.memdump", canonicalSize)), - []byte("garbage"), 0o644)) + []byte("garbage"), 0o600)) store, err := NewSRSStore(dir) assert.NoError(err) _, _, err = store.GetSRS(context.TODO(), cs) From 2167c7af3c3533ec10e4b6289e31414a4662f073 Mon Sep 17 00:00:00 2001 From: Coenie Beyers Date: Thu, 6 Aug 2026 09:40:59 +0200 Subject: [PATCH 31/31] chore(prover): exclude derived srs dumps from the prover-assets s3 sync Signed-off-by: Coenie Beyers --- prover/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prover/Makefile b/prover/Makefile index 940bf719b56..ce30d2402ba 100644 --- a/prover/Makefile +++ b/prover/Makefile @@ -66,7 +66,7 @@ setup: bin/prover ## Copy the prover assets to the S3 bucket (zkuat) ## copy-prover-assets: - aws s3 sync --exclude "*prover/dev*" --exclude "*05b9ef1*" --exclude "*05b9ef1*" --exclude "*96e3a19*" --exclude "*a9e4681*" prover-assets s3://zk-uat-prover/prover-assets/ --profile=zk-uat-s3-access + aws s3 sync --exclude "*prover/dev*" --exclude "*05b9ef1*" --exclude "*05b9ef1*" --exclude "*96e3a19*" --exclude "*a9e4681*" --exclude "*_derived*" prover-assets s3://zk-uat-prover/prover-assets/ --profile=zk-uat-s3-access download-srs: aws s3 sync s3://prover-assets/kzgsrs/ ./prover-assets/kzgsrs --exclude "*" --include "*.memdump"