Repository: o1-labs/zkapp-cli
Pull request #730 (2025-04-24 08:57:24)

 Frontend Circuit Cache
## Summary

This PR adds a `Frontend Circuit Cache` to the default `NextJS UI` project generated with the `zkapp-CLI`. Big thanks to @kriss1897 @marekyggdrasil @dfstio  @youtpout who all provided valuable insights on various caching solutions!

## Impact

Many developers in the community have attempted different solutions to create a compiled circuit cache in the browser to obtain performant UIs. These solutions often were complex to integrate into a project, creating a barrier for many to use a cache in their own projects 

The introduction of the `Frontend Circuit Cache` in the default generated NextJS project will enable developers to build more performant UIs by providing an example that can be built upon in their own projects.

## Changes

- Generate cache script added to create a cache and a list of cached methods in the contracts directory
- Copy cache to UI script added
- `FileSystem` object created in UI with read and write methods
- `FetchFiles` method created in UI
- `ZK project` flow updated to generate a cache and 
- Tests were updated to reflect new `ZK project` flow


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) Report
Attention: Patch coverage is `65.00000%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 99.53%. Comparing base [(`c1f010b`)](https://app.codecov.io/gh/o1-labs/zkapp-cli/commit/c1f010b6489b4d307e000161693046432daad944?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) to head [(`655adcd`)](https://app.codecov.io/gh/o1-labs/zkapp-cli/commit/655adcd5f2d29903d7d1d88c80d05d81528b2b03?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs).

| [Files with missing lines](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) | Patch % | Lines |
|---|---|---|
| [src/lib/project.js](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730?src=pr&el=tree&filepath=src%2Flib%2Fproject.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs#diff-c3JjL2xpYi9wcm9qZWN0Lmpz) | 65.00% | [5 Missing and 2 partials :warning: ](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #730      +/-   ##
===========================================
- Coverage   100.00%   99.53%   -0.47%     
===========================================
  Files           11       11              
  Lines         1481     1500      +19     
  Branches       302      305       +3     
===========================================
+ Hits          1481     1493      +12     
- Misses           0        5       +5     
- Partials         0        2       +2     
```

| [Flag](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs) | `99.53% <65.00%> (-0.47%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/o1-labs/zkapp-cli/pull/730?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=o1-labs).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
45930: This is great!  I tried it locally, and the only issue is it seems like the main JS thread is blocked by the compile.  I am definitely in favor of layering workers on top in a future PR.
ymekuria: > This is great! I tried it locally, and the only issue is it seems like the main JS thread is blocked by the compile. I am definitely in favor of layering workers on top in a future PR.

I definitely want to add workers in a follow up PR!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c49dba..c5003a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,12 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 
 ## Unreleased
 
+## [0.22.8](https://github.com/o1-labs/zkapp-cli/compare/v0.22.7...v0.22.8) - 2025-05-8
+
+### Added
+
+- Adds a frontend circuit cache to the default NextJS UI project generated with the zkapp-CLI. [#730](https://github.com/o1-labs/zkapp-cli/pull/730)
+
 ## [0.22.7](https://github.com/o1-labs/zkapp-cli/compare/v0.22.6...v0.22.7) - 2025-04-13
 
 ### Changed
diff --git a/package-lock.json b/package-lock.json
index 0b54ddd..c387fa5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "zkapp-cli",
-  "version": "0.22.7",
+  "version": "0.22.8",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "zkapp-cli",
-      "version": "0.22.7",
+      "version": "0.22.8",
       "license": "Apache-2.0",
       "dependencies": {
         "acorn": "^8.14.1",
diff --git a/package.json b/package.json
index 2e8e141..6c42a7b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "zkapp-cli",
-  "version": "0.22.7",
+  "version": "0.22.8",
   "description": "CLI to create zkApps (zero-knowledge apps) for Mina Protocol",
   "homepage": "https://github.com/o1-labs/zkapp-cli/",
   "repository": {
diff --git a/src/lib/project.js b/src/lib/project.js
index 5053535..5e7c917 100644
--- a/src/lib/project.js
+++ b/src/lib/project.js
@@ -155,7 +155,19 @@ async function project({ name, ui }) {
     await shellExec('npm run build --silent');
   });
 
-  if (ui) shell.cd('..'); // back to project root
+  // Generates a circuit cache in contracts folder and copies a chachelist to UI
+  if (ui === 'next') {
+    await step('Generate circuit cache for UI', async () => {
+      await shellExec('npx tsx scripts/generate-cache.ts');
+    });
+
+    // Copy the circuit cache to ui/public/cache and the cachelist to ui/app
+    await step('Copy circuit cachelist to UI', async () => {
+      await shellExec('npx tsx scripts/copy-cache-to-ui.ts');
+    });
+  } else if (ui) {
+    shell.cd('..'); // Move back to project root for other UI types
+  }
 
   await step('Git init commit', async () => {
     await shellExec(
@@ -488,6 +500,8 @@ const __dirname = path.dirname(__filename);
   let x = fs.readJsonSync(path.join('ui', 'package.json'));
   x.scripts['ts-watch'] = 'tsc --noEmit --incremental --watch';
   x.scripts['build'] = 'next build --no-lint';
+  x.scripts['clear-cache'] =
+    'npx rimraf public/cache && npx rimraf app/cache.json && echo "UI Cache cleared successfully!"';
   x.type = 'module';
   fs.writeJSONSync(path.join('ui', 'package.json'), x, { spaces: 2 });
 
diff --git a/src/lib/ui/next/customNextPage.js b/src/lib/ui/next/customNextPage.js
index dde29e3..8801896 100644
--- a/src/lib/ui/next/customNextPage.js
+++ b/src/lib/ui/next/customNextPage.js
@@ -6,8 +6,9 @@ import GradientBG from '../components/GradientBG.js';
 import styles from '../styles/Home.module.css';
 import heroMinaLogo from '../public/assets/hero-mina-logo.svg';
 import arrowRightSmall from '../public/assets/arrow-right-small.svg';
-import {fetchAccount, Mina, PublicKey, Field, Proof} from "o1js";
+import {fetchAccount, Mina, PublicKey, Field, Proof, Cache} from "o1js";
 import { Add, AddZkProgram } from "../../contracts";
+import cacheJSONList from "./cache.json";
 
 // We've already deployed the Add contract on testnet at this address
 // https://minascan.io/devnet/account/B62qnfpb1Wz7DrW7279B8nR8m4yY6wGJz4dnbAdkzfeUkpyp8aB9VCp
@@ -33,7 +34,9 @@ export default function Home() {
       setZkProgramState(num.toString());
 
       // Compile the AddZkProgram
+      const cacheFiles = await fetchFiles();
       console.log("Compiling AddZkProgram");
+      // ZkProgram cache in the browser is currently not fully supported.
       await AddZkProgram.compile();
       
       // Initialize the AddZkProgram with the initial state of the zkapp
@@ -44,7 +47,7 @@ export default function Home() {
 
       // Compile the contract so that o1js has the proving key required to execute contract calls
       console.log("Compiling Add contract to generate proving and verification keys");
-      await Add.compile();
+      await Add.compile({ cache: FileSystem(cacheFiles) });
 
       setLoading(false);
     })();
@@ -119,6 +122,51 @@ export default function Home() {
     setLoading(false);  
  }, [proof]);
 
+  const fetchFiles = async () => {
+    const cacheJson = cacheJSONList;
+    const cacheListPromises = cacheJson.files.map(async (file) => {
+      const [header, data] = await Promise.all([
+        fetch(\`/cache/\${file}.header\`).then((res) => res.text()),
+        fetch(\`/cache/\${file}\`).then((res) => res.text()),
+      ]);
+      return { file, header, data };
+    });
+
+    const cacheList = await Promise.all(cacheListPromises);
+
+    return cacheList.reduce((acc: any, { file, header, data }) => {
+      acc[file] = { file, header, data };
+      return acc;
+    }, {});
+  };
+
+  const FileSystem = (files: any): Cache => ({
+    read({ persistentId, uniqueId, dataType }: any) {
+      if (!files[persistentId]) {
+        return undefined;
+      }
+
+      const currentId = files[persistentId].header;
+
+      if (currentId !== uniqueId) {
+        return undefined;
+      }
+
+      if (dataType === "string") {
+        console.log("found in cache:", { persistentId, uniqueId, dataType });
+
+        return new TextEncoder().encode(files[persistentId].data);
+      }
+      return undefined;
+    },
+
+    write({ persistentId, uniqueId, dataType }: any, data: any) {
+      console.log({ persistentId, uniqueId, dataType });
+    },
+
+    canWrite: true
+  });
+
   return (
     <>
       <Head>
diff --git a/templates/project-ts/cache/README.md b/templates/project-ts/cache/README.md
new file mode 100644
index 0000000..8d0d9da
--- /dev/null
+++ b/templates/project-ts/cache/README.md
@@ -0,0 +1,1 @@
+The generated cache files will be added to this directory 
\ No newline at end of file
diff --git a/templates/project-ts/package.json b/templates/project-ts/package.json
index 231c244..2acccf3 100644
--- a/templates/project-ts/package.json
+++ b/templates/project-ts/package.json
@@ -20,11 +20,13 @@
     "format": "prettier --write --ignore-unknown **/*",
     "test": "npm run build && find build/src -name '*.test.js' -exec node --test {} \\;",
     "testw": "npm run build && find build/src -name '*.test.js' -exec node --test --watch {} \\;",
-    "lint": "npx eslint src/* --fix"
+    "lint": "npx eslint src/* --fix",
+    "clear-cache": "npx rimraf cache/* !cache/README.md && npx rimraf cache.json && echo \"Cache cleared successfully!\""
   },
   "devDependencies": {
     "@babel/preset-env": "^7.16.4",
     "@babel/preset-typescript": "^7.16.0",
+    "@types/node": "^22.14.1",
     "@typescript-eslint/eslint-plugin": "^5.5.0",
     "@typescript-eslint/parser": "^5.5.0",
     "eslint": "^8.7.0",
diff --git a/templates/project-ts/scripts/copy-cache-to-ui.ts b/templates/project-ts/scripts/copy-cache-to-ui.ts
new file mode 100644
index 0000000..6ea9010
--- /dev/null
+++ b/templates/project-ts/scripts/copy-cache-to-ui.ts
@@ -0,0 +1,54 @@
+import fs from 'fs/promises';
+import path from 'path';
+
+async function copyCacheToUI() {
+  try {
+    // Define paths
+    const cacheDir = path.join('.', 'cache');
+    const uiPublicCacheDir = path.join('..', 'ui', 'public', 'cache');
+    const cacheJsonSource = path.join('cache.json');
+    const cacheJsonDest = path.join('..', 'ui', 'app', 'cache.json');
+
+    // Create UI cache directory if it doesn't exist
+    try {
+      await fs.mkdir(uiPublicCacheDir, { recursive: true });
+    } catch (err: any) {
+      if (err.code !== 'EEXIST') {
+        throw err;
+      }
+    }
+
+    // Read files from cache directory
+    const files = await fs.readdir(cacheDir);
+
+    // Copy each file except README.md
+    for (const file of files) {
+      if (file !== 'README.md') {
+        const sourceFile = path.join(cacheDir, file);
+        const destFile = path.join(uiPublicCacheDir, file);
+
+        const data = await fs.readFile(sourceFile);
+        await fs.writeFile(destFile, data);
+      }
+    }
+
+    // Copy cache.json to UI app directory
+    try {
+      const cacheJsonData = await fs.readFile(cacheJsonSource);
+      await fs.writeFile(cacheJsonDest, cacheJsonData);
+    } catch (err: any) {
+      if (err.code === 'ENOENT') {
+        console.log('cache.json not found, skipping');
+      } else {
+        throw err;
+      }
+    }
+
+    console.log('Cache files copied to UI successfully');
+  } catch (error) {
+    console.error('Error copying cache files:', error);
+    process.exit(1);
+  }
+}
+
+await copyCacheToUI();
diff --git a/templates/project-ts/scripts/generate-cache.ts b/templates/project-ts/scripts/generate-cache.ts
new file mode 100644
index 0000000..e03f3df
--- /dev/null
+++ b/templates/project-ts/scripts/generate-cache.ts
@@ -0,0 +1,41 @@
+import { Cache } from 'o1js';
+import fs from 'fs/promises';
+
+// @ts-expect-error - These imports will resolve in the generated project after the contract is built. Remove these comments in your project.
+const { Add } = await import('../build/src/Add.js');
+// @ts-expect-error - These imports will resolve in the generated project after the ZkProgram is built. Remove these comments in your project.
+const { AddZkProgram } = await import('../build/src/AddZkProgram.js');
+
+const cache_directory = 'cache';
+
+// Create a file system cache instance pointing to our cache directory
+// This allows o1js to store and retrieve compiled circuit artifacts
+const cache: Cache = Cache.FileSystem(cache_directory);
+
+// ZkProgram cache in the browser is currently not fully supported.
+await AddZkProgram.compile();
+// Compile the smart contract with the cache enabled
+await Add.compile({ cache });
+
+type CacheList = {
+  files: string[];
+};
+const cacheObj: CacheList = {
+  files: [],
+};
+
+const files = await fs.readdir(cache_directory);
+for (const fileName of files) {
+  if (!fileName.endsWith('.header')) {
+    cacheObj['files'].push(fileName);
+  }
+}
+
+const jsonCacheFile = `cache.json`;
+
+try {
+  await fs.writeFile(jsonCacheFile, JSON.stringify(cacheObj, null, 2));
+  console.log('JSON cached object successfully saved ');
+} catch (error) {
+  console.error('Error writing JSON file:', error);
+}


Test patch:
diff --git a/src/lib/project.test.js b/src/lib/project.test.js
index 5c4f90b..f348b9d 100644
--- a/src/lib/project.test.js
+++ b/src/lib/project.test.js
@@ -226,7 +226,7 @@ describe('project.js', () => {
         message: expect.any(Function),
         prefix: expect.any(Function),
       });
-      checkUiProjectSetup(shell.exec.mock.calls);
+      checkUiProjectSetup(shell.exec.mock.calls, false, true);
       checkIfProjectSetupSuccessful();
     });
 
@@ -505,7 +505,7 @@ describe('project.js', () => {
 
         await project({ name: 'test-project', ui: 'next' });
 
-        checkUiProjectSetup(shell.exec.mock.calls, true);
+        checkUiProjectSetup(shell.exec.mock.calls, true, true);
       } finally {
         Object.defineProperty(process, 'platform', {
           value: originalPlatform,
@@ -784,7 +784,11 @@ function checkProjectSetupNoUi(shellExecCalls) {
   );
 }
 
-function checkUiProjectSetup(shellExecCalls, isWindows = false) {
+function checkUiProjectSetup(
+  shellExecCalls,
+  isWindows = false,
+  isNext = false
+) {
   expect(shellExecCalls[0][0]).toBe(
     'npm install --silent > ' + (isWindows ? 'NUL' : '"/dev/null" 2>&1')
   );
@@ -793,9 +797,18 @@ function checkUiProjectSetup(shellExecCalls, isWindows = false) {
     'npm install --silent > ' + (isWindows ? 'NUL' : '"/dev/null" 2>&1')
   );
   expect(shellExecCalls[3][0]).toBe('npm run build --silent');
-  expect(shellExecCalls[4][0]).toBe(
-    'git add . && git commit -m "Init commit" -q -n && git branch -m main'
-  );
+
+  if (isNext) {
+    expect(shellExecCalls[4][0]).toBe('npx tsx scripts/generate-cache.ts');
+    expect(shellExecCalls[5][0]).toBe('npx tsx scripts/copy-cache-to-ui.ts');
+    expect(shellExecCalls[6][0]).toBe(
+      'git add . && git commit -m "Init commit" -q -n && git branch -m main'
+    );
+  } else {
+    expect(shellExecCalls[4][0]).toBe(
+      'git add . && git commit -m "Init commit" -q -n && git branch -m main'
+    );
+  }
 }
 
 function checkIfProjectSetupSuccessful() {