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" diff --git a/prover/README.md b/prover/README.md index 915f6835f32..0198849d5ad 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. 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 diff --git a/prover/circuits/srs_provider.go b/prover/circuits/srs_provider.go index d12945b2616..cca64baa4a4 100644 --- a/prover/circuits/srs_provider.go +++ b/prover/circuits/srs_provider.go @@ -12,6 +12,21 @@ 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. +// +// 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, force bool) error +} + type UnsafeSRSProvider struct { } diff --git a/prover/circuits/srs_store.go b/prover/circuits/srs_store.go index 590d937f0c5..5f981351527 100644 --- a/prover/circuits/srs_store.go +++ b/prover/circuits/srs_store.go @@ -5,16 +5,23 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "regexp" "sort" "strconv" + "strings" + "sync" + "sync/atomic" + "time" "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" @@ -23,15 +30,58 @@ import ( ) type SRSStore struct { + // mu guards entries; a file is safe to read unlocked because it is only + // 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 } type fsEntry struct { isCanonical bool size int path string + 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. +// +// 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 +// 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. +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,12 +95,23 @@ 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{} srsStore.entries[ecc.BW6_761] = []fsEntry{} - srsRegexp := regexp.MustCompile(`^(kzg_srs)_(canonical|lagrange)_(\d+)_(bls12377|bn254|bw6761)_(aleo|aztec|celo)\.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() { @@ -68,15 +129,16 @@ 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] + // 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") } @@ -84,6 +146,7 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { isCanonical: isCanonical, size: size, path: filepath.Join(rootDir, fileName), + source: source, }) } @@ -98,44 +161,185 @@ func NewSRSStore(rootDir string) (*SRSStore, error) { return srsStore, nil } +// 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. 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, _, err := store.resolveSRS(ccs, false) + 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. 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 +// 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 +// 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, 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()) + + 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, true) + if err != nil { + return err + } + if !derived { + // a loadable dump is already on disk; nothing to publish + return nil + } + return store.persistLagrange(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. +// 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, provisioning bool) (kzg.SRS, kzg.SRS, bool, error) { 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] { + for _, entry := range entries { if entry.isCanonical && entry.size >= sizeCanonical { 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 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 + } + 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)) + } + 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 { + // 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 corrupted point data — ReadDump copies bytes without + // validating them + err = pkG1OnCurve(srs) + } + if err != nil { + // a lagrange dump is reconstructible (unlike the canonical SRS): log + // 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 + } + lagrangeSRS = srs + break } if lagrangeSRS == nil { @@ -143,30 +347,204 @@ 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) - var err error - lagrangeSRS, err = toLagrange(canonicalSRS, 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 — 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) + } + lagrangeSRS, err := toLagrange(canonicalSRS, sizeLagrange) if err != nil { - return nil, nil, err + return nil, nil, false, err + } + return canonicalSRS, lagrangeSRS, true, nil + } + + return canonicalSRS, lagrangeSRS, false, 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 + }) +} + +// 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 +// 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. +// +// 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) persistLagrange(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, derivedSourceTag) + finalPath := filepath.Join(store.rootDir, fileName) + + f, err := os.CreateTemp(store.rootDir, fileName+".tmp") + 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 { + return fmt.Errorf("writing srs dump: %w", err) + } + if err := f.Sync(); err != nil { + return err + } + if err := f.Close(); err != nil { + 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 { + return err + } + if err := os.Rename(f.Name(), finalPath); err != nil { + return err + } + published = true + syncDir(store.rootDir) - return canonicalSRS, lagrangeSRS, nil + store.register(curveID, fsEntry{isCanonical: false, size: sizeLagrange, path: finalPath, source: derivedSourceTag}) + + logrus.Infof("persisted derived lagrange SRS to %s", finalPath) + 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. +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) { + 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 + // (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: diff --git a/prover/circuits/srs_store_test.go b/prover/circuits/srs_store_test.go index 53ee27ec109..666ae314468 100644 --- a/prover/circuits/srs_store_test.go +++ b/prover/circuits/srs_store_test.go @@ -1,9 +1,30 @@ package circuits import ( + "bytes" + "context" + "fmt" + "math/big" + "os" + "path/filepath" + "sort" + "sync" "testing" + "time" + "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/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" "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 +43,563 @@ 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.persistLagrange(lagrange, 8, tc.curveID)) + + // 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.persistLagrange(lagrange, 8, ecc.BN254) + }() + } + wg.Wait() + close(errs) + for err := range errs { + assert.NoError(err, "concurrent persistLagrange 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.persistLagrange(lagrange, 8, ecc.BN254)) + + leftovers, err := filepath.Glob(filepath.Join(dir, "*.tmp*")) + assert.NoError(err) + assert.Empty(leftovers, "persistLagrange 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_derived.memdump"), 0o700)) + 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) + 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() + + 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), 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) + _, 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(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) { + assert.NotContains(entry.path, ".memdump.tmp", "temp files must never be indexed") + } + }) +} + +func TestSRSStore_DeriveAndPersistLagrange(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 + 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)) + + // reading must not write: GetSRS derives in memory and leaves no trace + reader, err := NewSRSStore(dir) + assert.NoError(err) + _, lagrangeSRS, err := reader.GetSRS(context.TODO(), cs) + assert.NoError(err) + 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") + + // provisioning writes, under the derived tag and world-readable + store, err := NewSRSStore(dir) + assert.NoError(err) + 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))) + 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 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, false)) + 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")}, + // 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))}, + } { + 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 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)) + 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) + + // 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) + 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 + reloaded := kzg.NewSRS(ecc.BN254) + 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) + }) + } + + t.Run("rejects_lagrange_from_a_different_setup", func(t *testing.T) { + assert := require.New(t) + // 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))) + + // 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) + 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) + _, lagrangeSRS, err := store.GetSRS(context.TODO(), cs) + assert.NoError(err) + assert.NotNil(lagrangeSRS) + // 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) + 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) + }) + + 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") + } + 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) + + // 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 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))) + 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. 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) { + 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.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))) + 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") + }) + } + + 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") + }) +} + +// 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) + } +} + +// 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"), 0o600)) + 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") + // 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) { + 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() + 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() + 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() +} + +// 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 +} diff --git a/prover/cmd/prover/cmd/setup.go b/prover/cmd/prover/cmd/setup.go index 55418b27c62..51b7fa0c9a0 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; 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, force); 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 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..5c0b69a2fad --- /dev/null +++ b/prover/cmd/prover/cmd/setup_backfill_test.go @@ -0,0 +1,92 @@ +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" + "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, 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) + + 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") + + // 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") +} diff --git a/prover/config/config.go b/prover/config/config.go index 2452ee4147d..12b7a4cb28e 100644 --- a/prover/config/config.go +++ b/prover/config/config.go @@ -146,6 +146,18 @@ 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 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 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 Execution Execution DataAvailability DataAvailability `mapstructure:"data_availability"` 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 e392a182893..35c5ce69514 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" @@ -76,3 +77,57 @@ func TestMustFindModuleLimitsReturnsLongestMatch(t *testing.T) { } } } + +func TestPersistDerivedSRSDefaultsOn(t *testing.T) { + assert := require.New(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" +[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 TestShippedConfigsDoNotOptOutOfSRSWrites(t *testing.T) { + assert := require.New(t) + + // 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 + 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()) + 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") +}