@@ -79,8 +72,8 @@ const PollResult: React.FC = () => {
)}
diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol
index 8afb74bf91..2859d4f09b 100644
--- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol
+++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol
@@ -117,6 +117,32 @@ contract CRISPProgram is IE3Program, Ownable {
return e3Data[e3Id].paramsHash;
}
+ /// @notice Get the details about an E3 such as the merkle root of the census
+ /// @dev RoundData cannot be returned directly as it contains nested mappings
+ /// @param e3Id The E3 program ID
+ /// @return merkleRoot The census merkle root
+ /// @return paramsHash The hash of the E3 program params
+ /// @return numOptions The number of vote options
+ /// @return creditMode The credit mode for the round
+ /// @return inputRoot The current root of the input (votes) merkle tree
+ /// @return numberOfVotes The number of leaves in the input merkle tree
+ function getRoundData(
+ uint256 e3Id
+ )
+ public
+ view
+ returns (uint256 merkleRoot, bytes32 paramsHash, uint256 numOptions, CreditMode creditMode, uint256 inputRoot, uint40 numberOfVotes)
+ {
+ RoundData storage round = e3Data[e3Id];
+
+ merkleRoot = round.merkleRoot;
+ paramsHash = round.paramsHash;
+ numOptions = round.numOptions;
+ creditMode = round.creditMode;
+ inputRoot = round.votes._root(TREE_DEPTH);
+ numberOfVotes = round.votes.numberOfLeaves;
+ }
+
/// @inheritdoc IE3Program
function validate(
uint256 e3Id,
diff --git a/examples/CRISP/packages/crisp-contracts/package.json b/examples/CRISP/packages/crisp-contracts/package.json
index 255193ddb1..855f78ba9b 100644
--- a/examples/CRISP/packages/crisp-contracts/package.json
+++ b/examples/CRISP/packages/crisp-contracts/package.json
@@ -1,6 +1,6 @@
{
"name": "@crisp-e3/contracts",
- "version": "0.12.0",
+ "version": "0.13.0",
"type": "module",
"files": [
"contracts",
diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts
index d1b1a3d119..7d916cfd59 100644
--- a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts
+++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts
@@ -122,4 +122,66 @@ describe('CRISP Contracts', function () {
await crispProgram.publishInput(e3Id, encodedProof)
})
})
+
+ describe('get round data', () => {
+ // Root of an empty LazyIMT of depth TREE_DEPTH (InternalLazyIMT.Z_20)
+ const EMPTY_TREE_ROOT = 15019797232609675441998260052101280400536945603062888308240081994073687793470n
+ // MockInterfold calls validate with empty e3ProgramParams
+ const EMPTY_PARAMS_HASH = ethers.keccak256('0x')
+
+ it('should return empty data for an e3 which was not initialized', async () => {
+ const e3Id = await mockInterfold.nextE3Id()
+
+ const [merkleRoot, paramsHash, numOptions, creditMode, inputRoot, numberOfVotes] = await crispProgram.getRoundData(e3Id)
+
+ expect(merkleRoot).to.equal(0n)
+ expect(paramsHash).to.equal(ethers.ZeroHash)
+ expect(numOptions).to.equal(0n)
+ expect(creditMode).to.equal(0n)
+ expect(inputRoot).to.equal(EMPTY_TREE_ROOT)
+ expect(numberOfVotes).to.equal(0n)
+ })
+
+ it('should return the data set by validate', async () => {
+ const e3Id = await mockInterfold.nextE3Id()
+ await mockInterfold.request(await crispProgram.getAddress())
+
+ const [merkleRoot, paramsHash, numOptions, creditMode, inputRoot, numberOfVotes] = await crispProgram.getRoundData(e3Id)
+
+ expect(merkleRoot).to.equal(0n)
+ expect(paramsHash).to.equal(EMPTY_PARAMS_HASH)
+ expect(numOptions).to.equal(2n)
+ // CreditMode.CONSTANT
+ expect(creditMode).to.equal(0n)
+ expect(inputRoot).to.equal(EMPTY_TREE_ROOT)
+ expect(numberOfVotes).to.equal(0n)
+ })
+
+ it('should return the merkle root of the census once set', async () => {
+ const e3Id = await mockInterfold.nextE3Id()
+ await mockInterfold.request(await crispProgram.getAddress())
+
+ const merkleTree = generateMerkleTree(leaves)
+ await crispProgram.setMerkleRoot(e3Id, merkleTree.root)
+
+ const [merkleRoot] = await crispProgram.getRoundData(e3Id)
+
+ expect(merkleRoot).to.equal(BigInt(merkleTree.root))
+ })
+
+ it('should reflect a published vote in the input tree', async function () {
+ const e3Id = await mockInterfold.nextE3Id()
+ await mockInterfold.request(await crispProgram.getAddress())
+
+ const merkleTree = generateMerkleTree(leaves)
+ await mockInterfold.setCommitteePublicKey(voteProof.publicInputs[6])
+ await crispProgram.setMerkleRoot(e3Id, merkleTree.root)
+ await crispProgram.publishInput(e3Id, encodeSolidityProof(voteProof))
+
+ const [, , , , inputRoot, numberOfVotes] = await crispProgram.getRoundData(e3Id)
+
+ expect(numberOfVotes).to.equal(1n)
+ expect(inputRoot).to.not.equal(EMPTY_TREE_ROOT)
+ })
+ })
})
diff --git a/examples/CRISP/packages/crisp-sdk/package.json b/examples/CRISP/packages/crisp-sdk/package.json
index 99eaff52f6..8f09ea7695 100644
--- a/examples/CRISP/packages/crisp-sdk/package.json
+++ b/examples/CRISP/packages/crisp-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@crisp-e3/sdk",
- "version": "0.12.0",
+ "version": "0.13.0",
"type": "module",
"author": {
"name": "gnosisguild",
diff --git a/examples/CRISP/packages/crisp-sdk/src/chain.ts b/examples/CRISP/packages/crisp-sdk/src/chain.ts
new file mode 100644
index 0000000000..57aad4667a
--- /dev/null
+++ b/examples/CRISP/packages/crisp-sdk/src/chain.ts
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: LGPL-3.0-only
+//
+// This file is provided WITHOUT ANY WARRANTY;
+// without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE.
+
+import { createPublicClient, http } from 'viem'
+import { localhost, sepolia } from 'viem/chains'
+
+import type { PublicClient } from 'viem'
+
+/**
+ * Create a public client for one of the chains supported by CRISP
+ * @param chainId - The chain ID of the network
+ * @returns The public client for the given chain
+ */
+export const getPublicClient = (chainId: number): PublicClient => {
+ let chain
+ switch (chainId) {
+ case 11155111:
+ chain = sepolia
+ break
+ case 31337:
+ chain = localhost
+ break
+ default:
+ throw new Error('Unsupported chainId')
+ }
+
+ return createPublicClient({
+ transport: http(),
+ chain,
+ })
+}
diff --git a/examples/CRISP/packages/crisp-sdk/src/index.ts b/examples/CRISP/packages/crisp-sdk/src/index.ts
index ca1b0a7c58..ebf0e2e9e0 100644
--- a/examples/CRISP/packages/crisp-sdk/src/index.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/index.ts
@@ -31,6 +31,7 @@ export {
export { CrispSDK } from './sdk'
export type {
+ OnChainRoundData,
RoundDetails,
TokenDetails,
Vote,
diff --git a/examples/CRISP/packages/crisp-sdk/src/sdk.ts b/examples/CRISP/packages/crisp-sdk/src/sdk.ts
index fd6b444f5c..eb1e69af1b 100644
--- a/examples/CRISP/packages/crisp-sdk/src/sdk.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/sdk.ts
@@ -17,7 +17,7 @@ import {
getVoteStatus,
requestNewRound,
} from './api'
-import { getPreviousCiphertext, getRoundDetails, getRoundTokenDetails } from './state'
+import { getOnChainRoundData, getPreviousCiphertext, getRoundDetails, getRoundTokenDetails } from './state'
import { generateMaskVoteProof, generateVoteProof } from './vote'
import type {
@@ -28,6 +28,7 @@ import type {
JsonResponse,
MaskVoteProofRequest,
NewRoundRequest,
+ OnChainRoundData,
ProofData,
RoundDetails,
TokenDetails,
@@ -179,6 +180,22 @@ export class CrispSDK {
return getRoundDetails(this.serverUrl, e3Id)
}
+ /**
+ * Get the round data stored in the CRISPProgram contract, read directly from the chain.
+ *
+ * When the chain id is omitted it is looked up on the CRISP server.
+ *
+ * @param programAddress - The address of the CRISPProgram contract
+ * @param e3Id - The e3Id of the round
+ * @param chainId - The chain ID of the network the program is deployed on
+ * @returns The on chain round data
+ */
+ async getOnChainRoundData(programAddress: string, e3Id: number, chainId?: number): Promise
{
+ const chain = chainId ?? Number((await getRoundDetails(this.serverUrl, e3Id)).chainId)
+
+ return getOnChainRoundData(programAddress, e3Id, chain)
+ }
+
/**
* Get the token address, balance threshold and snapshot block for a specific round.
* @param e3Id - The e3Id of the round
diff --git a/examples/CRISP/packages/crisp-sdk/src/state.ts b/examples/CRISP/packages/crisp-sdk/src/state.ts
index 4eb8deaa5c..873ca7e5b5 100644
--- a/examples/CRISP/packages/crisp-sdk/src/state.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/state.ts
@@ -4,10 +4,13 @@
// without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE.
+import { parseAbi } from 'viem'
+
import { CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT } from './constants'
import { getRoundStateLite } from './api'
+import { getPublicClient } from './chain'
-import type { RoundDetails, TokenDetails } from './types'
+import type { CreditMode, OnChainRoundData, RoundDetails, TokenDetails } from './types'
/**
* Get the details of a specific round in a camelCase convenience format
@@ -53,6 +56,40 @@ export const getRoundTokenDetails = async (serverUrl: string, e3Id: number): Pro
}
}
+/**
+ * Get the round data stored in the CRISPProgram contract, such as the merkle root
+ * of the census and the merkle root of the encrypted votes published so far.
+ *
+ * Unlike {@link getRoundDetails}, this reads directly from the chain and so does not
+ * depend on the CRISP server.
+ *
+ * @param programAddress - The address of the CRISPProgram contract
+ * @param e3Id - The e3Id of the round
+ * @param chainId - The chain ID of the network the program is deployed on
+ * @returns The on chain round data
+ */
+export const getOnChainRoundData = async (programAddress: string, e3Id: number, chainId: number): Promise => {
+ const publicClient = getPublicClient(chainId)
+
+ const [merkleRoot, paramsHash, numOptions, creditMode, inputRoot, numberOfVotes] = await publicClient.readContract({
+ address: programAddress as `0x${string}`,
+ abi: parseAbi([
+ 'function getRoundData(uint256 e3Id) view returns (uint256 merkleRoot, bytes32 paramsHash, uint256 numOptions, uint8 creditMode, uint256 inputRoot, uint40 numberOfVotes)',
+ ]),
+ functionName: 'getRoundData',
+ args: [BigInt(e3Id)],
+ })
+
+ return {
+ merkleRoot,
+ paramsHash,
+ numOptions,
+ creditMode: creditMode as CreditMode,
+ inputRoot,
+ numberOfVotes: BigInt(numberOfVotes),
+ }
+}
+
/**
* Get the previous ciphertext for a slot from the CRISP server.
* Returns undefined when the slot is empty (404).
diff --git a/examples/CRISP/packages/crisp-sdk/src/token.ts b/examples/CRISP/packages/crisp-sdk/src/token.ts
index f0ff355488..ceb02b1b71 100644
--- a/examples/CRISP/packages/crisp-sdk/src/token.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/token.ts
@@ -6,8 +6,9 @@
import { CRISP_SERVER_TOKEN_TREE_ENDPOINT } from './constants'
-import { createPublicClient, http, parseAbi } from 'viem'
-import { localhost, sepolia } from 'viem/chains'
+import { parseAbi } from 'viem'
+
+import { getPublicClient } from './chain'
/**
* Get the merkle tree data from the CRISP server
@@ -44,22 +45,7 @@ export const getTreeData = async (serverUrl: string, e3Id: number): Promise => {
- let chain
- switch (chainId) {
- case 11155111:
- chain = sepolia
- break
- case 31337:
- chain = localhost
- break
- default:
- throw new Error('Unsupported chainId')
- }
-
- const publicClient = createPublicClient({
- transport: http(),
- chain,
- })
+ const publicClient = getPublicClient(chainId)
const balance = (await publicClient.readContract({
address: tokenAddress as `0x${string}`,
@@ -79,22 +65,7 @@ export const getBalanceAt = async (voterAddress: string, tokenAddress: string, s
* @returns The total supply as a bigint
*/
export const getTotalSupplyAt = async (tokenAddress: string, snapshotBlock: number, chainId: number): Promise => {
- let chain
- switch (chainId) {
- case 11155111:
- chain = sepolia
- break
- case 31337:
- chain = localhost
- break
- default:
- throw new Error('Unsupported chainId')
- }
-
- const publicClient = createPublicClient({
- transport: http(),
- chain,
- })
+ const publicClient = getPublicClient(chainId)
const totalSupply = (await publicClient.readContract({
address: tokenAddress as `0x${string}`,
diff --git a/examples/CRISP/packages/crisp-sdk/src/types.ts b/examples/CRISP/packages/crisp-sdk/src/types.ts
index 13ca528411..a53f14f3e7 100644
--- a/examples/CRISP/packages/crisp-sdk/src/types.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/types.ts
@@ -29,6 +29,24 @@ export type RoundDetails = {
credits?: bigint
}
+/**
+ * Type representing the round data stored in the CRISPProgram contract
+ */
+export type OnChainRoundData = {
+ /// The merkle root of the census
+ merkleRoot: bigint
+ /// The hash of the E3 program params
+ paramsHash: `0x${string}`
+ /// The number of vote options
+ numOptions: bigint
+ /// The credit mode of the round
+ creditMode: CreditMode
+ /// The root of the merkle tree holding the encrypted votes
+ inputRoot: bigint
+ /// The number of votes published on chain
+ numberOfVotes: bigint
+}
+
/**
* Type representing the token details required for participation in a round
*/
diff --git a/examples/CRISP/packages/crisp-sdk/tests/state.test.ts b/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
index e1e7d30fbc..f79330c74e 100644
--- a/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
+++ b/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
@@ -6,13 +6,19 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
-import { getRoundDetails, getRoundTokenDetails } from '../src/state'
+import { getOnChainRoundData, getRoundDetails, getRoundTokenDetails } from '../src/state'
import { CRISP_SERVER_URL } from './constants'
import { CRISP_SERVER_STATE_LITE_ENDPOINT } from '../src/constants'
import { zeroAddress } from 'viem'
import { CreditMode } from '../src/types'
import type { E3StateLiteResponse } from '../src/types'
+const { readContract } = vi.hoisted(() => ({ readContract: vi.fn() }))
+
+vi.mock('../src/chain', () => ({
+ getPublicClient: () => ({ readContract }),
+}))
+
describe('State', () => {
const mockStateLiteResponse: E3StateLiteResponse = {
id: 0,
@@ -99,6 +105,58 @@ describe('State', () => {
})
})
+ describe('getOnChainRoundData', () => {
+ const programAddress = '0x1111111111111111111111111111111111111111'
+ const paramsHash = `0x${'ab'.repeat(32)}` as const
+ const inputRoot = 987654321n
+
+ it('should read the round data from the CRISPProgram contract', async () => {
+ readContract.mockResolvedValueOnce([100n, paramsHash, 2n, 0, inputRoot, 3])
+
+ const roundData = await getOnChainRoundData(programAddress, 5, 31337)
+
+ expect(roundData.merkleRoot).toBe(100n)
+ expect(roundData.paramsHash).toBe(paramsHash)
+ expect(roundData.numOptions).toBe(2n)
+ expect(roundData.creditMode).toBe(CreditMode.CONSTANT)
+ expect(roundData.inputRoot).toBe(inputRoot)
+ // uint40 is returned as a number by viem, but normalized to a bigint
+ expect(roundData.numberOfVotes).toBe(3n)
+
+ expect(readContract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: programAddress,
+ functionName: 'getRoundData',
+ args: [5n],
+ }),
+ )
+ })
+
+ it('should return the custom credit mode', async () => {
+ readContract.mockResolvedValueOnce([100n, paramsHash, 2n, 1, inputRoot, 3])
+
+ const roundData = await getOnChainRoundData(programAddress, 5, 31337)
+
+ expect(roundData.creditMode).toBe(CreditMode.CUSTOM)
+ })
+
+ it('should return zeroed data for a round which was not initialized', async () => {
+ readContract.mockResolvedValueOnce([0n, `0x${'00'.repeat(32)}`, 0n, 0, inputRoot, 0])
+
+ const roundData = await getOnChainRoundData(programAddress, 42, 31337)
+
+ expect(roundData.merkleRoot).toBe(0n)
+ expect(roundData.numOptions).toBe(0n)
+ expect(roundData.numberOfVotes).toBe(0n)
+ })
+
+ it('should propagate contract read errors', async () => {
+ readContract.mockRejectedValueOnce(new Error('execution reverted'))
+
+ await expect(getOnChainRoundData(programAddress, 5, 31337)).rejects.toThrow('execution reverted')
+ })
+ })
+
describe('getTokenDetails', () => {
it('should return the details of the token for a given e3Id from the CRISP server', async () => {
const mockResponse = mockStateLiteResponse
diff --git a/examples/CRISP/packages/crisp-zk-inputs/package.json b/examples/CRISP/packages/crisp-zk-inputs/package.json
index 391eddbd70..91589fdfbc 100644
--- a/examples/CRISP/packages/crisp-zk-inputs/package.json
+++ b/examples/CRISP/packages/crisp-zk-inputs/package.json
@@ -2,7 +2,7 @@
"name": "@crisp-e3/zk-inputs",
"type": "module",
"description": "Core logic to pre-compute CRISP ZK inputs (WASM/JavaScript bindings).",
- "version": "0.12.0",
+ "version": "0.13.0",
"license": "LGPL-3.0-only",
"repository": {
"type": "git",
diff --git a/examples/CRISP/scripts/publish.ts b/examples/CRISP/scripts/publish.ts
index e45733f28f..f2a7085df9 100644
--- a/examples/CRISP/scripts/publish.ts
+++ b/examples/CRISP/scripts/publish.ts
@@ -74,8 +74,9 @@ class CRISPPublisher {
console.log(' - @crisp-e3/sdk')
console.log(' - @crisp-e3/contracts')
console.log(' - @crisp-e3/zk-inputs')
+ console.log(' 6. Update the standalone client/pnpm-lock.yaml')
if (!this.options.skipGit) {
- console.log(' 6. Commit changes')
+ console.log(' 7. Commit changes')
}
console.log('\nโ
Dry run complete. Run without --dry-run to perform these actions.')
return
@@ -96,6 +97,9 @@ class CRISPPublisher {
// Publish packages
await this.publishPackages()
+ // Update the client lock file, which resolves the packages from npm
+ this.updateClientLockFile()
+
// Git operations (just commit, no tagging)
if (!this.options.skipGit && !this.options.dryRun) {
this.performGitOperations()
@@ -313,6 +317,29 @@ class CRISPPublisher {
}
}
+ /**
+ * Update the standalone client lock file.
+ *
+ * The client is deployed on its own (`pnpm install --ignore-workspace`, see
+ * client/.npmrc and client/vercel.json), so it keeps a lock file which resolves
+ * the CRISP packages from npm rather than from the workspace. It can only be
+ * refreshed once the new version has been published.
+ */
+ private updateClientLockFile(): void {
+ console.log('\n๐ Updating the client lock file...')
+
+ try {
+ execSync('pnpm install --ignore-workspace --lockfile-only', {
+ cwd: join(this.crispDir, 'client'),
+ stdio: 'pipe',
+ })
+ console.log(' โ client/pnpm-lock.yaml updated')
+ } catch {
+ console.warn(' โ ๏ธ Could not update client/pnpm-lock.yaml')
+ console.warn(' Vercel installs with a frozen lock file and will fail until it is regenerated')
+ }
+ }
+
/**
* Validate version format (semantic versioning)
*/
@@ -475,7 +502,8 @@ The script will:
4. Update pnpm-lock.yaml
5. Build packages
6. Publish to npm
- 7. Commit changes (no tags)
+ 7. Update the standalone client/pnpm-lock.yaml
+ 8. Commit changes (no tags)
Note: Make sure you're logged in to npm (npm login) before publishing.
`)
diff --git a/packages/interfold-react/src/useInterfoldSDK.ts b/packages/interfold-react/src/useInterfoldSDK.ts
index 051fa7536e..f1920f1b7d 100644
--- a/packages/interfold-react/src/useInterfoldSDK.ts
+++ b/packages/interfold-react/src/useInterfoldSDK.ts
@@ -17,6 +17,8 @@ import {
SDKError,
} from '@interfold/sdk'
+type RequestE3Params = Parameters[0]
+
export interface UseInterfoldSDKConfig {
contracts?: {
interfold: `0x${string}`
@@ -113,9 +115,12 @@ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKR
}
}, [publicClient, walletClient, config.contracts, config.thresholdBfvParamsPresetName])
- // Initialize SDK when wagmi clients are available
+ // The SDK is an external system with its own lifecycle (event subscriptions +
+ // cleanup), so it is created in an effect and mirrored into state rather than
+ // being derived during render.
useEffect(() => {
if (config.autoConnect && publicClient && !isInitialized) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
initializeSDK()
}
}, [config.autoConnect, publicClient, isInitialized, initializeSDK])
@@ -123,6 +128,7 @@ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKR
// Re-initialize when wallet client changes (connect/disconnect)
useEffect(() => {
if (isInitialized && publicClient && walletClient) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
initializeSDK()
}
}, [walletClient, initializeSDK, isInitialized, publicClient])
@@ -142,9 +148,9 @@ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKR
}, [sdk])
const requestE3 = useCallback(
- (...args: Parameters) => {
+ (params: RequestE3Params) => {
if (!sdk) throw new Error('SDK not initialized')
- return sdk.requestE3(...args)
+ return sdk.requestE3(params)
},
[sdk],
)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4db2ed469..38d9d6babe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -153,7 +153,7 @@ importers:
examples/CRISP/client:
dependencies:
'@crisp-e3/sdk':
- specifier: 0.12.0
+ specifier: 0.13.0
version: link:../packages/crisp-sdk
'@emotion/babel-plugin':
specifier: ^11.11.0
From e3f0aabb65de4b82b3deabf298d2895df3903d66 Mon Sep 17 00:00:00 2001
From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com>
Date: Tue, 28 Jul 2026 12:57:19 +0100
Subject: [PATCH 3/4] chore: add snaphost block (#1751)
---
examples/CRISP/client/package.json | 2 +-
examples/CRISP/client/pnpm-lock.yaml | 18 +++----
examples/CRISP/client/src/model/vote.model.ts | 1 +
examples/CRISP/crates/zk-inputs/src/utils.rs | 7 +--
.../packages/crisp-contracts/package.json | 2 +-
.../CRISP/packages/crisp-sdk/package.json | 2 +-
.../CRISP/packages/crisp-sdk/src/state.ts | 3 +-
.../CRISP/packages/crisp-sdk/src/types.ts | 4 ++
.../packages/crisp-sdk/tests/state.test.ts | 5 +-
.../packages/crisp-zk-inputs/package.json | 2 +-
examples/CRISP/scripts/publish.ts | 49 ++++++++++++++-----
examples/CRISP/server/src/server/indexer.rs | 17 +++++--
examples/CRISP/server/src/server/models.rs | 7 +++
examples/CRISP/server/src/server/repo.rs | 38 ++++++++++++++
.../CRISP/server/src/server/routes/state.rs | 2 +-
.../src/server/token_holders/etherscan.rs | 6 +--
pnpm-lock.yaml | 2 +-
17 files changed, 126 insertions(+), 41 deletions(-)
diff --git a/examples/CRISP/client/package.json b/examples/CRISP/client/package.json
index bc38c9803a..585a2c8934 100644
--- a/examples/CRISP/client/package.json
+++ b/examples/CRISP/client/package.json
@@ -18,7 +18,7 @@
"deploy": "gh-pages -d dist"
},
"dependencies": {
- "@crisp-e3/sdk": "0.13.0",
+ "@crisp-e3/sdk": "0.14.0",
"@emotion/babel-plugin": "^11.11.0",
"@emotion/react": "^11.11.4",
"@phosphor-icons/react": "^2.1.4",
diff --git a/examples/CRISP/client/pnpm-lock.yaml b/examples/CRISP/client/pnpm-lock.yaml
index b69764c465..ae31e9471b 100644
--- a/examples/CRISP/client/pnpm-lock.yaml
+++ b/examples/CRISP/client/pnpm-lock.yaml
@@ -16,8 +16,8 @@ importers:
.:
dependencies:
'@crisp-e3/sdk':
- specifier: 0.13.0
- version: 0.13.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)
+ specifier: 0.14.0
+ version: 0.14.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)
'@emotion/babel-plugin':
specifier: ^11.11.0
version: 11.13.5
@@ -731,11 +731,11 @@ packages:
'@coinbase/wallet-sdk@4.3.6':
resolution: {integrity: sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==}
- '@crisp-e3/sdk@0.13.0':
- resolution: {integrity: sha512-hJKn4w3sy7zNZ41Y7Hmc7jj0m6b0a9I0hcGS3rgGyzIdlA4DyWnvikMvPfXlyPnxfUQf0j+u4a7xgBZXZ6+0GA==}
+ '@crisp-e3/sdk@0.14.0':
+ resolution: {integrity: sha512-yO5r2tYzYPUTIDM5r5eQltKJFwgw33ehToAu4YOo1i67d1y8oNLJqoZ+2kmoHDtN1fUe/hPliRDiQ7CHqaHAew==}
- '@crisp-e3/zk-inputs@0.13.0':
- resolution: {integrity: sha512-QfD9RtucEhBJYHRBqC16LaLod7N4LmawMqeNw+jys3Z6eRRnYSgba2/m3gHFt9ZxZ8ArImQ3puTV01T1iYa3fg==}
+ '@crisp-e3/zk-inputs@0.14.0':
+ resolution: {integrity: sha512-ZPHwvQJ/iFb1BrqsGjbxjXfld7oEuEQwt9Av9YnTf8r2vDotLV09TUUxsinhzeCGM4Kg9c+nKe8YUZdFTRFyqg==}
'@ecies/ciphers@0.2.6':
resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
@@ -5629,10 +5629,10 @@ snapshots:
- utf-8-validate
- zod
- '@crisp-e3/sdk@0.13.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)':
+ '@crisp-e3/sdk@0.14.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)':
dependencies:
'@aztec/bb.js': 3.0.0-nightly.20260102
- '@crisp-e3/zk-inputs': 0.13.0
+ '@crisp-e3/zk-inputs': 0.14.0
'@noir-lang/noir_js': 1.0.0-beta.16
'@zk-kit/lean-imt': 2.2.5(bufferutil@4.1.0)(utf-8-validate@5.0.10)
poseidon-lite: 0.3.0
@@ -5643,7 +5643,7 @@ snapshots:
- utf-8-validate
- zod
- '@crisp-e3/zk-inputs@0.13.0': {}
+ '@crisp-e3/zk-inputs@0.14.0': {}
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
diff --git a/examples/CRISP/client/src/model/vote.model.ts b/examples/CRISP/client/src/model/vote.model.ts
index f44c3df623..cea6f7e4fd 100644
--- a/examples/CRISP/client/src/model/vote.model.ts
+++ b/examples/CRISP/client/src/model/vote.model.ts
@@ -52,6 +52,7 @@ export interface VoteStateLite {
start_time: number
end_time: number
start_block: number
+ snapshot_block: number
committee_public_key: number[]
emojis: [string, string]
diff --git a/examples/CRISP/crates/zk-inputs/src/utils.rs b/examples/CRISP/crates/zk-inputs/src/utils.rs
index eada5477b9..f4708a1467 100644
--- a/examples/CRISP/crates/zk-inputs/src/utils.rs
+++ b/examples/CRISP/crates/zk-inputs/src/utils.rs
@@ -22,9 +22,10 @@ pub fn numbers_to_strings_for_js(value: serde_json::Value) -> serde_json::Value
if u > JS_SAFE_INT_MAX as u64 {
return serde_json::Value::String(u.to_string());
}
- } else if n.as_f64().is_none_or(|f| {
- f < -JS_SAFE_INT_MAX as f64 || f > JS_SAFE_INT_MAX as f64
- }) {
+ } else if n
+ .as_f64()
+ .is_none_or(|f| f < -JS_SAFE_INT_MAX as f64 || f > JS_SAFE_INT_MAX as f64)
+ {
return serde_json::Value::String(n.to_string());
}
diff --git a/examples/CRISP/packages/crisp-contracts/package.json b/examples/CRISP/packages/crisp-contracts/package.json
index 855f78ba9b..486799e80a 100644
--- a/examples/CRISP/packages/crisp-contracts/package.json
+++ b/examples/CRISP/packages/crisp-contracts/package.json
@@ -1,6 +1,6 @@
{
"name": "@crisp-e3/contracts",
- "version": "0.13.0",
+ "version": "0.14.0",
"type": "module",
"files": [
"contracts",
diff --git a/examples/CRISP/packages/crisp-sdk/package.json b/examples/CRISP/packages/crisp-sdk/package.json
index 8f09ea7695..33033b313c 100644
--- a/examples/CRISP/packages/crisp-sdk/package.json
+++ b/examples/CRISP/packages/crisp-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@crisp-e3/sdk",
- "version": "0.13.0",
+ "version": "0.14.0",
"type": "module",
"author": {
"name": "gnosisguild",
diff --git a/examples/CRISP/packages/crisp-sdk/src/state.ts b/examples/CRISP/packages/crisp-sdk/src/state.ts
index 873ca7e5b5..1b5677eecd 100644
--- a/examples/CRISP/packages/crisp-sdk/src/state.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/state.ts
@@ -32,6 +32,7 @@ export const getRoundDetails = async (serverUrl: string, e3Id: number): Promise<
startTime: BigInt(data.start_time),
endTime: BigInt(data.end_time),
startBlock: BigInt(data.start_block),
+ snapshotBlock: BigInt(data.snapshot_block),
committeePublicKey: new Uint8Array(data.committee_public_key),
emojis: data.emojis,
numOptions: BigInt(data.num_options),
@@ -52,7 +53,7 @@ export const getRoundTokenDetails = async (serverUrl: string, e3Id: number): Pro
return {
tokenAddress: roundDetails.tokenAddress,
threshold: roundDetails.balanceThreshold,
- snapshotBlock: roundDetails.startBlock,
+ snapshotBlock: roundDetails.snapshotBlock,
}
}
diff --git a/examples/CRISP/packages/crisp-sdk/src/types.ts b/examples/CRISP/packages/crisp-sdk/src/types.ts
index a53f14f3e7..50c41ec46f 100644
--- a/examples/CRISP/packages/crisp-sdk/src/types.ts
+++ b/examples/CRISP/packages/crisp-sdk/src/types.ts
@@ -18,7 +18,10 @@ export type RoundDetails = {
voteCount: bigint
startTime: bigint
endTime: bigint
+ /// The block the E3 was requested at
startBlock: bigint
+ /// The block the census was built at
+ snapshotBlock: bigint
committeePublicKey: Uint8Array
emojis: [string, string]
tokenAddress: string
@@ -189,6 +192,7 @@ export type E3StateLiteResponse = {
start_time: number
end_time: number
start_block: number
+ snapshot_block: number
committee_public_key: number[]
emojis: [string, string]
token_address: string
diff --git a/examples/CRISP/packages/crisp-sdk/tests/state.test.ts b/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
index f79330c74e..53cf7b7f01 100644
--- a/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
+++ b/examples/CRISP/packages/crisp-sdk/tests/state.test.ts
@@ -29,6 +29,7 @@ describe('State', () => {
start_time: 1000000,
end_time: 1086400,
start_block: 12345,
+ snapshot_block: 12344,
committee_public_key: [1, 2, 3],
emojis: ['๐', '๐'],
token_address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
@@ -69,6 +70,7 @@ describe('State', () => {
expect(state.startTime).toBe(1000000n)
expect(state.endTime).toBe(1086400n)
expect(state.startBlock).toBe(12345n)
+ expect(state.snapshotBlock).toBe(12344n)
expect(state.committeePublicKey).toEqual(new Uint8Array([1, 2, 3]))
expect(state.emojis).toEqual(['๐', '๐'])
expect(state.tokenAddress).toBe('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd')
@@ -175,7 +177,8 @@ describe('State', () => {
expect(tokenDetails.threshold).toBeGreaterThan(0)
expect(tokenDetails.threshold).toBe(1000n)
expect(tokenDetails.snapshotBlock).toBeGreaterThan(0)
- expect(tokenDetails.snapshotBlock).toBe(12345n)
+ // the census is built at the block before the request, not at the request block
+ expect(tokenDetails.snapshotBlock).toBe(12344n)
})
})
})
diff --git a/examples/CRISP/packages/crisp-zk-inputs/package.json b/examples/CRISP/packages/crisp-zk-inputs/package.json
index 91589fdfbc..9baeb25eb4 100644
--- a/examples/CRISP/packages/crisp-zk-inputs/package.json
+++ b/examples/CRISP/packages/crisp-zk-inputs/package.json
@@ -2,7 +2,7 @@
"name": "@crisp-e3/zk-inputs",
"type": "module",
"description": "Core logic to pre-compute CRISP ZK inputs (WASM/JavaScript bindings).",
- "version": "0.13.0",
+ "version": "0.14.0",
"license": "LGPL-3.0-only",
"repository": {
"type": "git",
diff --git a/examples/CRISP/scripts/publish.ts b/examples/CRISP/scripts/publish.ts
index f2a7085df9..4a439502c3 100644
--- a/examples/CRISP/scripts/publish.ts
+++ b/examples/CRISP/scripts/publish.ts
@@ -98,7 +98,7 @@ class CRISPPublisher {
await this.publishPackages()
// Update the client lock file, which resolves the packages from npm
- this.updateClientLockFile()
+ await this.updateClientLockFile()
// Git operations (just commit, no tagging)
if (!this.options.skipGit && !this.options.dryRun) {
@@ -325,18 +325,45 @@ class CRISPPublisher {
* the CRISP packages from npm rather than from the workspace. It can only be
* refreshed once the new version has been published.
*/
- private updateClientLockFile(): void {
+ private async updateClientLockFile(): Promise {
console.log('\n๐ Updating the client lock file...')
- try {
- execSync('pnpm install --ignore-workspace --lockfile-only', {
- cwd: join(this.crispDir, 'client'),
- stdio: 'pipe',
- })
- console.log(' โ client/pnpm-lock.yaml updated')
- } catch {
- console.warn(' โ ๏ธ Could not update client/pnpm-lock.yaml')
- console.warn(' Vercel installs with a frozen lock file and will fail until it is regenerated')
+ const clientDir = join(this.crispDir, 'client')
+
+ // The registry needs a moment to serve a freshly published version, and the
+ // install below resolves from it rather than from the workspace.
+ await this.waitForRegistry('@crisp-e3/sdk')
+
+ execSync('pnpm install --ignore-workspace --lockfile-only', {
+ cwd: clientDir,
+ stdio: 'pipe',
+ })
+
+ // A stale registry cache can resolve the previous version without failing, which
+ // would produce a lock file that Vercel then rejects.
+ const lockFile = readFileSync(join(clientDir, 'pnpm-lock.yaml'), 'utf-8')
+ if (!lockFile.includes(`'@crisp-e3/sdk@${this.newVersion}'`)) {
+ throw new Error(`client/pnpm-lock.yaml does not reference @crisp-e3/sdk@${this.newVersion} after the install`)
+ }
+
+ console.log(' โ client/pnpm-lock.yaml updated')
+ }
+
+ /**
+ * Wait for a freshly published version to be visible on the npm registry.
+ */
+ private async waitForRegistry(packageName: string, attempts = 10): Promise {
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ execSync(`npm view ${packageName}@${this.newVersion} version`, { stdio: 'pipe' })
+ return
+ } catch {
+ if (attempt === attempts) {
+ throw new Error(`${packageName}@${this.newVersion} is not available on the registry after ${attempts} attempts`)
+ }
+ console.log(` โฆ waiting for ${packageName}@${this.newVersion} on the registry (${attempt}/${attempts})`)
+ await new Promise((resolve) => setTimeout(resolve, 3000))
+ }
}
}
diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs
index a04feb86c3..8c11ce372d 100644
--- a/examples/CRISP/server/src/server/indexer.rs
+++ b/examples/CRISP/server/src/server/indexer.rs
@@ -101,6 +101,10 @@ pub async fn register_e3_requested(
let input_window = [e3.inputWindow[0].to::(), e3.inputWindow[1].to::()];
+ // The census is built at the block before the request, as the request block
+ // itself is not final when the E3 is requested.
+ let snapshot_block = event.e3.requestBlock.to::().saturating_sub(1);
+
// Get token holders from Etherscan API or mocked data.
let token_holders = if matches!(CONFIG.chain_id, 31337 | 1337) {
info!(
@@ -127,8 +131,7 @@ pub async fn register_e3_requested(
etherscan_client
.get_token_holders_with_constant_balance(
token_address,
- // the block is the one before the request
- event.e3.requestBlock.to::() - 1u64,
+ snapshot_block,
credits_u256
)
.await
@@ -138,8 +141,7 @@ pub async fn register_e3_requested(
etherscan_client
.get_token_holders_with_voting_power(
token_address,
- // the block is the one before the request
- event.e3.requestBlock.to::() - 1u64,
+ snapshot_block,
&CONFIG.http_rpc_url,
U256::from_str_radix(&balance_threshold.to_string(), 10).map_err(
|e| {
@@ -166,7 +168,12 @@ pub async fn register_e3_requested(
}
// save the e3 details
- repo.initialize_round(custom_params, e3.requester.to_string(), input_window[1])
+ repo.initialize_round(
+ custom_params,
+ e3.requester.to_string(),
+ input_window[1],
+ snapshot_block,
+ )
.await?;
// Store eligible addresses in the repository.
diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs
index 8804c4c74f..6bd8c1f85e 100644
--- a/examples/CRISP/server/src/server/models.rs
+++ b/examples/CRISP/server/src/server/models.rs
@@ -171,7 +171,10 @@ pub struct E3StateLite {
pub start_time: u64,
pub end_time: u64,
+ /// The block the E3 was requested at
pub start_block: u64,
+ /// The block the census was built at
+ pub snapshot_block: u64,
pub committee_public_key: Vec,
pub emojis: [String; 2],
@@ -239,6 +242,10 @@ pub struct E3Crisp {
pub num_options: String,
pub credit_mode: CreditMode,
pub credits: Option,
+ /// The block the census was built at. Defaults to 0 for rounds stored before
+ /// this field existed, which is resolved when the round state is read.
+ #[serde(default)]
+ pub snapshot_block: u64,
}
impl From for WebResultRequest {
diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs
index d5c2a0716a..f672cac25f 100644
--- a/examples/CRISP/server/src/server/repo.rs
+++ b/examples/CRISP/server/src/server/repo.rs
@@ -170,6 +170,7 @@ impl CrispE3Repository {
custom_params: CustomParams,
requester: String,
end_time: u64,
+ snapshot_block: u64,
) -> Result<()> {
self.set_crisp(E3Crisp {
has_voted: vec![],
@@ -187,6 +188,7 @@ impl CrispE3Repository {
credit_mode: custom_params.credit_mode,
credits: custom_params.credits,
end_time,
+ snapshot_block,
})
.await
}
@@ -276,6 +278,7 @@ impl CrispE3Repository {
pub async fn get_e3_state_lite(&self) -> Result {
let e3 = self.get_e3().await?;
let e3_crisp = self.get_crisp().await?;
+ let snapshot_block = snapshot_block(e3.request_block, e3_crisp.snapshot_block);
Ok(E3StateLite {
emojis: e3_crisp.emojis,
id: self.e3_id,
@@ -285,6 +288,7 @@ impl CrispE3Repository {
end_time: e3.input_window[1],
vote_count: u64::try_from(e3_crisp.has_voted.len())?,
start_block: e3.request_block,
+ snapshot_block,
interfold_address: e3.interfold_address,
committee_public_key: e3.committee_public_key,
token_address: e3_crisp.token_address,
@@ -399,3 +403,37 @@ impl CrispE3Repository {
format!("_e3:crisp:{e3_id}")
}
}
+
+/// The block the census was built at.
+///
+/// Rounds stored before the snapshot block was persisted fall back to the block before
+/// the request, which is what the indexer used to build their census.
+///
+/// `stored_snapshot_block` is the value persisted on the round, 0 when it is missing.
+fn snapshot_block(request_block: u64, stored_snapshot_block: u64) -> u64 {
+ if stored_snapshot_block == 0 {
+ request_block.saturating_sub(1)
+ } else {
+ stored_snapshot_block
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::snapshot_block;
+
+ #[test]
+ fn returns_the_stored_snapshot_block() {
+ assert_eq!(snapshot_block(100, 99), 99);
+ }
+
+ #[test]
+ fn falls_back_to_the_block_before_the_request() {
+ assert_eq!(snapshot_block(100, 0), 99);
+ }
+
+ #[test]
+ fn does_not_underflow_on_the_genesis_block() {
+ assert_eq!(snapshot_block(0, 0), 0);
+ }
+}
diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs
index 7fc2710cd8..85b36c6955 100644
--- a/examples/CRISP/server/src/server/routes/state.rs
+++ b/examples/CRISP/server/src/server/routes/state.rs
@@ -15,7 +15,7 @@ use crate::server::{
CONFIG,
};
use actix_web::{web, HttpResponse, Responder};
-use alloy::primitives::{Address, B256, Bytes, U256};
+use alloy::primitives::{Address, Bytes, B256, U256};
use e3_sdk::evm_helpers::contracts::{
InterfoldContract, InterfoldContractFactory, InterfoldWrite, ReadWrite,
};
diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs
index 14e31aa4b4..30129dab5c 100644
--- a/examples/CRISP/server/src/server/token_holders/etherscan.rs
+++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs
@@ -323,11 +323,7 @@ impl EtherscanClient {
let decimals = Self::get_decimals(token_address, rpc_url).await?;
// we want to keep some precision but want to deal with as small as numbers as possible
- let precision = if decimals > 1 {
- decimals - 1
- } else {
- 0
- };
+ let precision = if decimals > 1 { decimals - 1 } else { 0 };
let scale_factor = U256::from(10u128.pow(precision as u32));
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 38d9d6babe..41fb2d6337 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -153,7 +153,7 @@ importers:
examples/CRISP/client:
dependencies:
'@crisp-e3/sdk':
- specifier: 0.13.0
+ specifier: 0.14.0
version: link:../packages/crisp-sdk
'@emotion/babel-plugin':
specifier: ^11.11.0
From fada09e31d832e3839a3a113c22d4fb6b3c3e900 Mon Sep 17 00:00:00 2001
From: Toby1009 <69885352+Toby1009@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:46:39 +0800
Subject: [PATCH 4/4] fix(crisp): install interfold CLI matching the active dev
profile
`scripts/lib/dev_config.sh` exports E3_NODES__CN*__SKIP_PROOF_AGGREGATION
from the active profile (default true), but `setup.sh` installed the CLI
without the matching `test-only-skip-proof-aggregation` Cargo feature.
Every ciphernode then exited at startup.
The failure was silent: `dev_cipher.sh` started the swarm with
`interfold nodes up -v &` and never checked it, so the script still
registered the ciphernodes on-chain, wrote the ready file, and brought up
the UI. The only symptom was "No active poll found".
CI does not hit this because `build_interfold_cli` already passes the
feature explicitly and `crisp_e2e` consumes that artifact instead of
running `setup.sh`. This makes `setup.sh` agree with CI.
- select the Cargo feature from the active profile
- always reinstall, so a stale binary cannot survive a checkout change
- wait for every node to report Started before registering ciphernodes,
requiring the count to hold across consecutive samples
- match the STATUS column exactly, since node names come from the config
- terminate the swarm supervisor on exit so it is not orphaned
Co-Authored-By: Claude Opus 5 (1M context)
---
examples/CRISP/scripts/dev_cipher.sh | 39 +++++++++++++++++++++++++++-
examples/CRISP/scripts/setup.sh | 16 ++++++++----
2 files changed, 49 insertions(+), 6 deletions(-)
diff --git a/examples/CRISP/scripts/dev_cipher.sh b/examples/CRISP/scripts/dev_cipher.sh
index f4f1469616..eb0fae27a5 100755
--- a/examples/CRISP/scripts/dev_cipher.sh
+++ b/examples/CRISP/scripts/dev_cipher.sh
@@ -32,8 +32,45 @@ sync_interfold_circuit_artifacts
# using & instead of -d so that wait works below
interfold nodes up -v &
+SWARM_PID=$!
-sleep 2
+# `nodes up` keeps running even when every node it supervises has exited, so leaving it behind on
+# an early exit would orphan it - and the next run wipes .interfold/data out from under it.
+cleanup_swarm() {
+ kill -TERM "$SWARM_PID" 2>/dev/null || true
+}
+trap cleanup_swarm EXIT
+
+# A node counts as `Started` as soon as its process is spawned, which is earlier than the point
+# where it is actually usable, so a single sample can catch a node that is about to die. Require
+# the full count to hold across consecutive samples instead.
+# Match the STATUS column exactly: node names come from this config, so a substring match over
+# the whole line could be satisfied by a node name rather than by a real status.
+EXPECTED_NODES=$(yq -r '.nodes | length' ./interfold.config.yaml)
+REQUIRED_STABLE_SAMPLES=3
+STABLE_SAMPLES=0
+STARTED_NODES=0
+
+for _ in $(seq 1 60); do
+ STARTED_NODES=$(interfold nodes ps 2>/dev/null | awk 'NR > 1 && $2 == "Started"' | wc -l | tr -d ' ')
+ if [[ "$STARTED_NODES" -eq "$EXPECTED_NODES" ]]; then
+ STABLE_SAMPLES=$((STABLE_SAMPLES + 1))
+ else
+ STABLE_SAMPLES=0
+ fi
+ if [[ "$STABLE_SAMPLES" -ge "$REQUIRED_STABLE_SAMPLES" ]]; then
+ break
+ fi
+ sleep 1
+done
+
+if [[ "$STABLE_SAMPLES" -lt "$REQUIRED_STABLE_SAMPLES" ]]; then
+ echo "ERROR: only ${STARTED_NODES}/${EXPECTED_NODES} ciphernodes stayed up. Current status:" >&2
+ interfold nodes ps >&2 || true
+ echo "See the node output above for the cause. If it mentions a missing Cargo feature, the" >&2
+ echo "installed interfold binary does not match this dev profile - re-run 'pnpm dev:setup'." >&2
+ exit 1
+fi
CN1=$(cat ./interfold.config.yaml | yq -r '.nodes.cn1.address')
CN2=$(cat ./interfold.config.yaml | yq -r '.nodes.cn2.address')
diff --git a/examples/CRISP/scripts/setup.sh b/examples/CRISP/scripts/setup.sh
index 9d7641f2a0..dea187c7f8 100755
--- a/examples/CRISP/scripts/setup.sh
+++ b/examples/CRISP/scripts/setup.sh
@@ -26,11 +26,17 @@ apply_crisp_dev_config_to_server_env
echo "client"
(cd ./client && if [[ ! -f .env ]]; then cp .env.example .env; fi)
echo "ciphernode"
-if [[ ! -f ~/.cargo/bin/interfold ]]; then
- echo "Building and installing interfold CLI..."
- (cd "${REPO_ROOT}" && cargo build --locked -p e3-cli && cargo install --locked --path crates/cli)
-else
- echo "interfold CLI already installed, skipping build"
+# `load_crisp_dev_config` exports E3_NODES__CN*__SKIP_PROOF_AGGREGATION from this profile.
+# The node rejects that setting unless the binary carries the matching Cargo feature, so the
+# feature selection has to follow the profile or every ciphernode exits on startup.
+INTERFOLD_FEATURES=""
+if [[ "$CRISP_SKIP_PROOF_AGGREGATION" == "true" ]]; then
+ INTERFOLD_FEATURES="--features test-only-skip-proof-aggregation"
fi
+echo "Building and installing interfold CLI (${INTERFOLD_FEATURES:-no extra features})..."
+# Always reinstall: `cargo install --path` rebuilds and replaces in place, so a stale binary
+# from an earlier checkout (or an earlier profile) cannot silently survive.
+# shellcheck disable=SC2086
+(cd "${REPO_ROOT}" && cargo install --locked --path crates/cli $INTERFOLD_FEATURES)
print_crisp_dev_config_summary