From 6a0b00db4908350a02f80e3741f1e855d58bfed7 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Thu, 10 Sep 2026 21:56:49 +0500 Subject: [PATCH 1/2] fix: strengthen test coverage and reduce repeated app work --- .github/workflows/ci.yml | 44 ++- TEST_AND_APP_OPTIMIZATION_REVIEW.md | 348 ++++++++++++++++++ agent/CONTEXT.md | 39 +- crates/net/src/event_buffer/actor.rs | 11 +- crates/net/src/event_buffer/tests.rs | 55 ++- .../tests/fold_accumulators_e2e_tests.rs | 67 ++-- .../tests/node_fold_correlated_e2e_tests.rs | 22 +- .../tests/slashing_integration_tests.rs | 330 ++++++++++------- examples/CRISP/client/package.json | 4 + .../client/src/components/CircularTiles.tsx | 32 -- .../client/src/components/CountdownTime.tsx | 56 +-- .../voteManagement/VoteManagement.context.tsx | 174 +++++---- .../client/src/hooks/generic/useFetchApi.tsx | 48 ++- .../src/hooks/interfold/useInterfoldServer.ts | 112 +++--- .../src/hooks/voting/useArchivePolls.ts | 54 +++ examples/CRISP/client/src/model/poll.model.ts | 5 + .../client/src/pages/AllPolls/AllPolls.tsx | 68 ++-- .../src/pages/PollResult/PollResult.tsx | 21 +- .../client/src/pages/RoundPoll/RoundPoll.tsx | 19 +- .../client/src/utils/estimated-chain-clock.ts | 70 ++++ .../tests/estimated-chain-clock.test.ts | 63 ++++ .../client/tests/useArchivePolls.test.ts | 103 ++++++ .../CRISP/client/tests/useFetchApi.test.ts | 92 +++++ examples/CRISP/client/vitest.config.ts | 8 + .../packages/crisp-sdk/tests/utils.test.ts | 18 +- examples/CRISP/server/src/server/indexer.rs | 3 + examples/CRISP/server/src/server/models.rs | 44 +++ examples/CRISP/server/src/server/repo.rs | 288 +++++++++++++-- .../CRISP/server/src/server/routes/state.rs | 191 +++++++++- .../server/tests/fixtures/round-index-v0.json | 1 + package.json | 10 +- .../test/MockSlashingBondingRegistry.sol | 42 +++ .../test/Governance/AccessAndBounds.spec.ts | 48 +-- packages/interfold-dashboard/package.json | 2 + packages/interfold-dashboard/src/lib/e3.ts | 150 +++++--- .../src/lib/event-history.ts | 141 +++++++ .../tests/e3-cache.test.ts | 96 +++++ .../tests/event-history.test.ts | 164 +++++++++ packages/interfold-react/package.json | 4 + .../interfold-react/src/useInterfoldSDK.ts | 78 ++-- .../tests/useInterfoldSDK.test.ts | 112 ++++++ packages/interfold-react/vitest.config.ts | 10 + packages/interfold-sdk/package.json | 3 +- .../src/circuits/assert-minimum-circuits.ts | 34 +- .../src/crypto/user-data-encryption-prover.ts | 94 +++++ .../src/crypto/user-data-encryption.ts | 132 ++----- .../tests/circuit-selection.test.ts | 49 +++ .../integration/encryption-proof.test.ts | 70 ++++ .../interfold-sdk/tests/proof-api.test.ts | 70 ++++ packages/interfold-sdk/tests/sdk.test.ts | 37 +- packages/interfold-sdk/vitest.config.ts | 9 + .../interfold-sdk/vitest.proofs.config.ts | 14 + pnpm-lock.yaml | 63 +++- 53 files changed, 3029 insertions(+), 793 deletions(-) create mode 100644 TEST_AND_APP_OPTIMIZATION_REVIEW.md delete mode 100644 examples/CRISP/client/src/components/CircularTiles.tsx create mode 100644 examples/CRISP/client/src/hooks/voting/useArchivePolls.ts create mode 100644 examples/CRISP/client/src/utils/estimated-chain-clock.ts create mode 100644 examples/CRISP/client/tests/estimated-chain-clock.test.ts create mode 100644 examples/CRISP/client/tests/useArchivePolls.test.ts create mode 100644 examples/CRISP/client/tests/useFetchApi.test.ts create mode 100644 examples/CRISP/client/vitest.config.ts create mode 100644 examples/CRISP/server/tests/fixtures/round-index-v0.json create mode 100644 packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol create mode 100644 packages/interfold-dashboard/src/lib/event-history.ts create mode 100644 packages/interfold-dashboard/tests/e3-cache.test.ts create mode 100644 packages/interfold-dashboard/tests/event-history.test.ts create mode 100644 packages/interfold-react/tests/useInterfoldSDK.test.ts create mode 100644 packages/interfold-react/vitest.config.ts create mode 100644 packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts create mode 100644 packages/interfold-sdk/tests/circuit-selection.test.ts create mode 100644 packages/interfold-sdk/tests/integration/encryption-proof.test.ts create mode 100644 packages/interfold-sdk/tests/proof-api.test.ts create mode 100644 packages/interfold-sdk/vitest.config.ts create mode 100644 packages/interfold-sdk/vitest.proofs.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d152778e1d..c7ed017434 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: net: ${{ steps.jobs.outputs.net }} init: ${{ steps.jobs.outputs.init }} build_sdk: ${{ steps.jobs.outputs.build_sdk }} + web_tests: ${{ steps.jobs.outputs.web_tests }} build_e3_support_dev: ${{ steps.jobs.outputs.build_e3_support_dev }} build_circuits: ${{ steps.jobs.outputs.build_circuits }} integration_prebuild: ${{ steps.jobs.outputs.integration_prebuild }} @@ -114,6 +115,12 @@ jobs: - 'crates/wasm/**' - '!**/*.md' - '!**/*.mdx' + web: + - 'packages/interfold-react/**' + - 'packages/interfold-dashboard/**' + - 'examples/CRISP/client/**' + - 'pnpm-lock.yaml' + - 'package.json' integration_tests: - 'tests/integration/**' - '!**/*.md' @@ -135,6 +142,7 @@ jobs: CRISP="${{ steps.filter.outputs.crisp }}" TEMPLATES="${{ steps.filter.outputs.templates }}" SDK="${{ steps.filter.outputs.sdk }}" + WEB="${{ steps.filter.outputs.web }}" INTEGRATION="${{ steps.filter.outputs.integration_tests }}" DOCKER="${{ steps.filter.outputs.docker }}" CI="${{ steps.filter.outputs.ci }}" @@ -146,6 +154,7 @@ jobs: echo "rust_integration_tests=$(any $FORCE $RUST $CONTRACTS $CIRCUITS $CI)" >> $GITHUB_OUTPUT echo "ciphernode_e2e=$(any $FORCE $RUST $CONTRACTS $CIRCUITS $INTEGRATION $CI)" >> $GITHUB_OUTPUT echo "build_sdk=$(any $FORCE $RUST $CONTRACTS $SDK $INTEGRATION $CIRCUITS $CI $TEMPLATES)" >> $GITHUB_OUTPUT + echo "web_tests=$(any $FORCE $WEB $SDK $CI)" >> $GITHUB_OUTPUT # CRISP jobs (unit legs and e2e alike) only guard CRISP's own layers. # Cross-cutting ciphernode coverage comes from template_integration, # which drives the same full E3 lifecycle with the same interfold @@ -225,6 +234,9 @@ jobs: - name: Run Unit Tests run: 'cargo test --lib && cargo test --doc' + - name: Run Rust-to-Solidity slashing assertions + run: pnpm rust:test:slashing + - name: Cancel workflow on failure if: failure() run: gh run cancel ${{ github.run_id }} @@ -1152,7 +1164,7 @@ jobs: zk_prover_e2e: needs: [detect_changes, build_circuits] if: needs.detect_changes.outputs.zk == 'true' - timeout-minutes: 30 + timeout-minutes: 60 runs-on: ${{ github.repository == 'theinterfold/interfold' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && format('runs-on={0}-{1}-{2}/runner=4cpu-linux-x64/ram=16', github.run_id, github.run_attempt, @@ -1233,6 +1245,12 @@ jobs: - name: Run ZK prover e2e tests run: cargo test -p e3-zk-prover --test local_e2e_tests -- --nocapture --test-threads=1 + - name: Verify fold accumulators and the correlated node proof + timeout-minutes: 30 + run: + cargo test --locked -p e3-zk-prover --test fold_accumulators_e2e_tests --test node_fold_correlated_e2e_tests -- --include-ignored + --nocapture --test-threads=1 + build_e3_support_dev: needs: [detect_changes] if: needs.detect_changes.outputs.build_e3_support_dev == 'true' @@ -1272,6 +1290,26 @@ jobs: retention-days: 1 if-no-files-found: error + web_tests: + needs: [detect_changes] + if: needs.detect_changes.outputs.web_tests == 'true' + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Install pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml + - name: Install test dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Test app behavior without circuit preparation + run: pnpm test:web + build_sdk: needs: [detect_changes] if: needs.detect_changes.outputs.build_sdk == 'true' @@ -1330,6 +1368,10 @@ jobs: - name: Run the tests run: pnpm sdk:test + - name: Verify SDK encryption proofs and reject altered bindings + timeout-minutes: 15 + run: pnpm sdk:test:proofs:prepared + - name: Upload SDK artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/TEST_AND_APP_OPTIMIZATION_REVIEW.md b/TEST_AND_APP_OPTIMIZATION_REVIEW.md new file mode 100644 index 0000000000..36efa01988 --- /dev/null +++ b/TEST_AND_APP_OPTIMIZATION_REVIEW.md @@ -0,0 +1,348 @@ +# Test quality and application performance review + +Date: 2026-09-08 + +## Scope and status + +This report records a repository-wide source scan and focused inspection of the suspect paths. The +reviewed checkout was `499146c971b0a4d65842a330ec9010b8dd091805`. Relevant findings were also +checked against the Avail candidate at `723bed2eeb93beb20900a444bf7c78aa2d8cff16`. + +The original review did not run runtime benchmarks or a complete test suite. The findings below +record the original behavior. The implementation section records subsequent fixes and local checks. +Generated verifiers and vendored dependencies were excluded from cleanup candidates. + +The main opportunities are stronger assertions, less repeated setup, and less repeated application +I/O. Test count alone does not measure useful coverage. + +## Implementation — 2026-09-10 + +All 12 findings are implemented on `fix/test-quality-and-app-performance`, originally created from +local `main` at `ab0ef64a83e113b951c94dd45ed31e730f6838b8`. The Jolt experiment is separate on +`feat/crisp-jolt-experiment` and is not part of this change. The results below describe local +checks, not a complete CI run or a deployment. + +| Finding | Change | Verification | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| T1 | One shared real encryption proof replaces two type-only proof tests. Separate wrapper tests check exact witness forwarding and error propagation. | The compiled verifier accepts the proof. Each of its five altered public inputs fails verification. Altered proof bytes fail decoding. | +| T2 | Required Rust proof and slashing targets fail on missing tools or artifacts. Ordinary runs report expensive integration tests as ignored. CI explicitly selects them. | All 8 fold tests, the correlated node proof, and 19 slashing tests pass. Seven slashing tests execute compiled contracts. | +| T3 | All 23 governance tests use the named deployment snapshot fixture. | The same assertions pass. Local suite time fell from about 8 seconds to 0.6 seconds. | +| T4 | Fast SDK tests no longer prepare circuits or load the prover. The SDK build prepares artifacts once for its CI job. | All 38 fast tests pass. The prepared proof command verifies real proofs without repeating preparation. | +| T5 | Utility tests compare a known Merkle root and exact signature components. Invalid-leaf checks remain. | All 7 utility tests pass. | +| T6 | Network tests wait for observable buffer state instead of sleeping. Delivery waits have explicit bounds. | All 5 event-buffer tests pass. | +| A1 | Dashboard event cursors advance incrementally. Completed state is cached, while fees and new reward events remain live. | 12 dashboard tests cover shared ranges, RPC counts, terminal state, reorgs, failed chunks, cancellation, and reset. | +| A2 | Countdown components share one clock per RPC client. Local ticks update the display between non-overlapping chain refreshes. | 3 clock tests cover shared requests, slow responses, retries, and cleanup. Contract deadlines remain authoritative. | +| A3 | The archive uses indexed requester positions and bounded server pages. The client fetches each page without an artificial delay. | Repository, HTTP, and hook tests cover legacy migration, replay, stable cursors, pending rows, full-width IDs, errors, and retry. | +| A4 | One effect owns each React SDK instance. Configuration values and client identities control its lifecycle. | 4 hook tests cover inline configuration, wallet changes, configuration changes, and cleanup. | +| C1 | The HTTP hook dispatches the requested Axios method, rejects failures, and counts concurrent requests. Endpoint names remain stable. | 9 hook tests cover methods, request options, suppressed 404 responses, rejected failures, and concurrent loading. | +| C2 | The unused `CircularTiles.tsx` component is removed after a reference check. | No application imports remain. Git retains the removed source. No bundle-size improvement is claimed. | + +### Proof preparation and repeated setup + +The fast wrapper suite mocks only the prover boundary. It still uses real WASM encryption and +compares the witness values. It does not claim cryptographic verification. The separate +[proof suite](packages/interfold-sdk/tests/integration/encryption-proof.test.ts) generates one +wrapper proof, which requires two inner proofs. All positive and negative assertions reuse it. + +Required Rust tests exposed stale fixture assumptions that the previous skip paths concealed. +Slashing tests now link the compiled evidence library and configure current E3 dependency snapshots. +A test-only bonding registry records requested penalties and lock release. These assertions test +slashing execution, not real token transfers. Existing production contracts are unchanged. + +The C3 fold fixture now uses the current minimum committee shape of six slots. A full circuit build +resolved locally mixed artifact shapes. No circuit, threshold, witness format, or proof algorithm +was changed. The circuit builder and its source checks remain unchanged. + +### App behavior and compatibility + +The dashboard validates the previous cached block hash before a refresh and the requested head hash +before committing results. A reorg invalidates event history and cached terminal state. Failed later +chunks commit neither earlier chunks nor cached values. A repeated head needs no new log requests. +Active stages still require contract reads. Terminal fees remain live. + +The archive endpoint is `POST /state/archive`. It accepts requester filters, a versioned cursor, and +a limit from 1 to 50 (default 12). It returns `items` and `next_cursor`. Requester filtering +precedes round reads. Each page reads at most `limit` round pairs, not every historical round. The +index itself remains one JSON record, so index deserialization still grows with archive size. + +Schema 1 adds requester positions without changing existing IDs or their order. Startup migrates +legacy indexes once. Failed migrations do not advance the schema version. New rounds do not shift an +existing cursor. Pending rounds consume positions but produce no summary. Empty pages can still have +a next cursor. The client retains that cursor and offers another page or retry. The legacy +`/state/all` endpoint remains available. + +### Local verification + +The counts below are command results, not a sum of independent coverage. Durations exclude setup and +compilation unless stated otherwise. They do not establish a CI-wide speedup. + +| Command | Result | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `pnpm evm:test test/Governance/AccessAndBounds.spec.ts` | 23 passed. About 8 seconds before fixture reuse, 0.6 seconds after. | +| `pnpm sdk:test` | 38 passed. Latest runner duration: 1.23 seconds, with no circuit preparation. | +| `pnpm sdk:test:proofs:prepared` | 7 passed. Latest proof-suite duration: 6.49 seconds. | +| `pnpm test:web` | 31 passed: React 4, dashboard 12, CRISP client 15. | +| `pnpm -C examples/CRISP test:sdk tests/utils.test.ts` | 7 passed. | +| `cargo test -p e3-net event_buffer -j 2` | 5 passed. | +| `pnpm rust:test:slashing` | 19 passed, including 7 contract-backed tests. No ignored tests. | +| `pnpm rust:test:proofs` | 8 fold tests and 1 correlated node test passed. No ignored tests. | +| `cargo test -p crisp --lib -j 2` in `examples/CRISP` | 117 passed, 6 existing external-RPC tests ignored. Includes the archive HTTP tests. | + +Root Rust checks used `CARGO_TARGET_DIR=examples/CRISP/target` and two build jobs to reuse the +working local compilation cache. `pnpm evm:build` and a full +`pnpm build:circuits --preset insecure-512 --committee minimum --skip-if-built` completed first. The +full circuit build produced all 24 circuits for one consistent pair. + +Scoped ESLint, client and dashboard TypeScript checks, SDK and React declaration builds, and +`git diff --check` pass. The committee, documentation, address, and invariant checks pass. The +initial license check reported the deleted `CircularTiles.tsx` because its tracked-file scan still +included the unstaged deletion. New source files include SPDX headers. + +Full `pnpm test`, the complete Noir test suite, live-RPC application benchmarks, and remote CI were +not run. The new fast web CI job does not prepare circuits. Existing prepared jobs now explicitly +run the required proof and slashing targets. + +## Test quality + +### T1. Expensive SDK proof tests do not verify proofs + +Source: [SDK tests](packages/interfold-sdk/tests/sdk.test.ts). + +The number and vector proof tests run the real proof-generation pipeline. They only check object +types and byte-array types. Neither test verifies the resulting proof or checks its public-input +binding. Each timeout is 9,999,999 milliseconds, almost 2 hours 47 minutes. + +The tests provide crash detection, but their assertions do not justify treating them as proof +correctness tests. + +Recommended changes: + +- Keep real proof generation in a dedicated cryptographic integration suite. +- Verify each proof against the expected public inputs and verification key. +- Reject altered commitments and public inputs in negative tests. +- Test API wrapper argument forwarding separately, without generating redundant proofs. +- Set explicit, justified integration timeouts. + +Acceptance: a valid proof passes, an altered binding fails, and a malformed proof object cannot +satisfy the test. + +Preserve the +[cryptographic compatibility unit](agent/INVARIANTS.md#noir--barretenberg-compatibility) and all +proof-binding requirements. + +### T2. Optional Rust integration tests can report success without running + +Sources: + +- [Fold tests](crates/zk-prover/tests/fold_accumulators_e2e_tests.rs) +- [Correlated fold tests](crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs) +- [Slashing integration tests](crates/zk-prover/tests/slashing_integration_tests.rs) +- [CI workflow](.github/workflows/ci.yml) + +Several tests print a skip message and return successfully when tools or artifacts are missing. +These test binaries are not among CI's explicit root integration-test targets. Other proof suites +are configured in CI, so this finding does not mean that CI runs no real proofs. + +Recommended changes: + +- Distinguish required integration tests from explicitly optional tests. +- Fail required jobs when a binary or artifact is missing. +- Use explicit test selection or ignored-test reporting for optional local tests. +- Check that every required integration-test target belongs to a CI job. + +Acceptance: missing prerequisites cannot produce a successful required proof or slashing job. Retain +the actual tests. They protect the repository's +[compatibility and evidence requirements](agent/INVARIANTS.md#meta-invariants). + +### T3. Governance tests repeat full-system deployment + +Source: +[Governance access and bounds tests](packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts). + +There are 23 direct calls to `deployAll()`. Each call deploys the protocol fixture before testing +ownership, limits, or configuration behavior. + +Recommended change: use a named `loadFixture` snapshot fixture, as other contract suites already do. +Keep the distinct ownership and bounds assertions. + +Acceptance: all assertions remain, and independent tests restore the same initial state. Measure +deployment count and suite duration before and after the change. + +### T4. SDK unit tests enter circuit preparation unnecessarily + +Sources: [SDK scripts](packages/interfold-sdk/package.json) and +[circuit compilation entry point](packages/interfold-sdk/scripts/compile-circuits.sh). + +The SDK `pretest` script invokes circuit preparation even for event-listener tests. The build +preparation invokes it too. The script does not request the source-checked `--skip-if-built` path. + +Recommended changes: + +- Separate event, contract-client, and wrapper tests from cryptographic integration tests. +- Hydrate and validate the required circuit artifacts once per cryptographic job. +- Keep source, preset, committee, compiler, and verification-key consistency checks. + +Acceptance: an event-only test does not invoke Nargo or Barretenberg. A cryptographic test fails +when its artifacts do not match the selected configuration. + +### T5. Weak utility tests overlap stronger neighboring tests + +Source: [CRISP SDK utility tests](examples/CRISP/packages/crisp-sdk/tests/utils.test.ts). + +One test checks only that a generated Merkle root exists. The neighboring proof test constructs the +tree and verifies a proof against it. The existence-only test adds little coverage. + +The signature-component test checks only four `Uint8Array` types, not their contents. + +Recommended changes: + +- Remove the existence-only test or replace it with a known-root vector. +- Check signature components against known expected values. +- Retain exact hash vectors, invalid-leaf tests, and server-format compatibility tests. + +Acceptance: incorrect root or signature contents fail even when the returned types are correct. + +### T6. Network tests depend on scheduling delays + +Source: [Network event-buffer tests](crates/net/src/event_buffer/tests.rs). + +`test_buffers_until_sync_ended` uses a 10-millisecond sleep and a 100-millisecond delivery timeout. +It also contains receives without a timeout. A regression can therefore hang the test, while a +loaded runner can miss a short delivery deadline. + +This is a timing risk found in source, not a reproduced flaky failure. + +Recommended changes: + +- Synchronize on observable actor progress instead of assuming that a sleep is sufficient. +- Give every receive a bounded failure path. +- Keep the assertions that events remain buffered until synchronization completes. + +Acceptance: the test rejects early delivery and lost delivery without depending on runner speed. +Preserve the [startup and replay ordering rules](agent/INVARIANTS.md#ordering-backpressure-effects). + +## Application performance + +### A1. The public dashboard repeatedly scans complete event history + +Sources: [Event queries](packages/interfold-dashboard/src/lib/e3.ts) and +[polling hooks](packages/interfold-dashboard/src/lib/useE3s.ts). + +The dashboard polls every 15 seconds. List refreshes scan E3 requests from the deployment block and +read stages for historical E3s. The CRISP view also scans historical ballots. Detail refreshes +repeat the request-history scan before querying the selected E3. + +Recommended changes: + +- Load history once and keep a cursor scoped to the chain and deployment. +- Fetch new events after the cursor. +- Cache immutable metadata and completed E3 results. +- Refresh active E3 state separately. +- Handle reorgs, overlapping ranges, duplicate events, and interrupted requests explicitly. + +Acceptance: a refresh with no new events does not rescan the deployment history. Reorg and replay +tests must still produce the correct view. Preserve +[stable event identity and replay semantics](agent/INVARIANTS.md#meta-invariants). + +### A2. The countdown requests a block every second + +Source: [CRISP countdown](examples/CRISP/client/src/components/CountdownTime.tsx). + +Each timer tick calls `getBlock()`. There is no in-flight guard, so slow requests can overlap. + +Recommended change: share the latest observed chain timestamp, advance the displayed estimate +locally, and refresh chain state periodically. Label the display as an estimate when needed. + +Acceptance: countdown ticks do not each require an RPC request. Transaction checks and acceptance +still use the [on-chain deadlines](agent/INVARIANTS.md#deadlines), not the browser clock. + +### A3. The poll archive delays display without fetching a new page + +Sources: [Archive page](examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx) and +[round-state routes](examples/CRISP/server/src/server/routes/state.rs). + +The archive already holds its results, then waits one second before increasing the visible slice. No +network request occurs inside that delay. The server reads all round records sequentially and +filters by requester after those reads. + +Recommended changes: + +- Remove the artificial one-second delay. +- Add server-side pagination and requester indexing. +- Return lightweight summaries for archive rows. + +Acceptance: already loaded rows appear without the delay. Fetching one archive page does not require +reading and returning every historical round. + +### A4. Inline configuration can repeatedly recreate the React SDK + +Source: [React SDK hook](packages/interfold-react/src/useInterfoldSDK.ts). + +The initialization callback depends on the identity of `config.contracts`. The documented inline +object changes identity on every render. With a connected wallet, initialization updates state and +can trigger another cleanup and initialization cycle. + +Recommended changes: + +- Key the lifecycle on stable configuration values and client identities. +- Use one initialization and cleanup effect. +- Test repeated renders with inline configuration, wallet changes, and unmounts. + +Acceptance: unchanged configuration does not reconstruct the SDK or discard its event subscriptions. +The existing template memoizes its configuration, so this finding does not claim that the template +currently enters an infinite loop. + +## Wrappers and unused code + +### C1. The generic HTTP wrapper hides method and error behavior + +Source: [CRISP HTTP hook](examples/CRISP/client/src/hooks/generic/useFetchApi.tsx). + +The wrapper accepts arbitrary Axios methods, but every method except lowercase `get` becomes POST. +It logs failures and returns `undefined`. One loading flag also represents concurrent requests. +Current endpoint callers mainly use GET and POST, so unsupported-method behavior is a latent API +defect rather than a demonstrated failing request. + +Recommended changes: + +- Narrow the supported methods or dispatch through the matching Axios request method. +- Preserve explicit error results or rejected promises. +- Track loading per request or per query. +- Keep useful domain-specific endpoint names. + +Acceptance: methods, failures, and concurrent loading states match the advertised API. + +### C2. CircularTiles has no application references + +Source: [CircularTiles](examples/CRISP/client/src/components/CircularTiles.tsx). + +No references were found, and the component is unreachable from the CRISP application's static +import graph. Remove it after a final reference check. This reduces maintenance clutter, not a +measured bundle size or runtime cost. + +## Code to retain + +- Commitment mismatch, replay, ordering, duplicate-event, timeout, and recovery tests. +- Rust, Solidity, and Noir tests that independently verify the same cross-language encoding. +- Actor and effect boundaries that enforce runtime architecture. +- Named contract fixtures that provide isolated protocol state. +- The node dashboard polling wrapper, which already handles cancellation, overlapping requests, and + background-tab polling. + +## Suggested implementation order + +1. Correct misleading test results and missing required integration targets. +2. Reuse contract fixtures and separate fast SDK tests from proof tests. +3. Correct the React SDK lifecycle and HTTP error behavior. +4. Remove repeated dashboard scans, countdown RPC calls, and artificial archive delays. +5. Remove confirmed dead code and redundant tests. +6. Compare test coverage, command timings, RPC counts, and application behavior before and after. + +No cleanup may silently change thresholds, commitments, proof multiplicity, witness formats, event +identity, or replay behavior. Such changes require their own compatibility review and tests. + +## Reference documentation + +- [React: unnecessary object dependencies](https://react.dev/reference/react/useEffect#removing-unnecessary-object-dependencies) +- [Hardhat network helpers](https://hardhat.org/docs/plugins/hardhat-network-helpers) +- [Repository invariants](agent/INVARIANTS.md) diff --git a/agent/CONTEXT.md b/agent/CONTEXT.md index f51d0a88da..23d0a2e42b 100644 --- a/agent/CONTEXT.md +++ b/agent/CONTEXT.md @@ -59,8 +59,13 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Install / build all | `pnpm i` · `pnpm build` | | Build Rust | `pnpm rust:build` (cargo `--locked --release`; prebuilds EVM fixtures) | -| Test everything | `pnpm test` (evm → rust → sdk → noir) | +| Test everything | `pnpm test` (EVM, Rust, required proof/slashing suites, SDK, web, Noir) | | Test one layer | `pnpm evm:test` · `pnpm rust:test` · `pnpm sdk:test` · `pnpm noir:test` | +| Fast app tests | `pnpm test:web` (React SDK, dashboard, CRISP client, no circuit preparation) | +| SDK proof verification | `pnpm sdk:test:proofs` (prepare circuits, generate one proof, verify bindings and reject tampering) | +| Prepared SDK proof tests | `pnpm sdk:test:proofs:prepared` (reuse the current SDK build or prepared circuit set) | +| Rust proof integration | `pnpm rust:test:proofs` (prepared insecure-512/minimum circuits and `bb`) | +| Rust slashing integration | `pnpm rust:test:slashing` (compiled contract artifacts and `anvil`) | | Integration tests | `pnpm test:integration [name]` (`--no-prebuild` to skip binary build) | | Lint / format | `pnpm lint` · `pnpm format` / `pnpm format:check` | | Build circuits | `pnpm build:circuits [--preset …] [--committee …]` (needs `nargo` + `bb`; `interfold noir setup` installs them) | @@ -70,6 +75,38 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | Prepare release branch | `pnpm bump:versions X.Y.Z` | | Tag merged release | `pnpm release:tag X.Y.Z` from updated `main` | +## Test preparation and app reads + +`pnpm sdk:test` runs the fast SDK suites without circuit preparation. The proof API tests mock the +prover boundary. They do not claim to verify cryptographic proofs. The separate proof suite verifies +a real proof against the compiled verification key and rejects altered public inputs and proof +bytes. + +Before `pnpm rust:test:proofs` or `pnpm test`, run +`pnpm build:circuits --preset insecure-512 --committee minimum --skip-if-built`. This prepares one +consistent set of inner and recursive circuits. Before `pnpm rust:test:slashing`, run +`pnpm evm:build`. The named Rust integration suites fail if a required tool or artifact is missing. +Ordinary Rust test runs report these integration tests as ignored. CI explicitly selects them. The +full test command reuses the prepared circuits for SDK proof verification. + +The dashboard keeps event cursors per client and deployment. It validates the previous block hash +before extending history. A reorg or an earlier requested height clears cached history and terminal +state. Failed or cancelled refreshes commit neither cursors nor cached values. Only on-chain +`Complete` and `Failed` stages stop stage polling. Display-time deadline estimates do not. + +CRISP serves archive pages at `POST /state/archive`. The request accepts `requesters`, an optional +`cursor`, and `limit` (default 12, maximum 50). The response contains `items` and `next_cursor`. +Each item is a lightweight result summary. A page reads at most `limit` round pairs after requester +filtering. Rounds without verified public-key state consume a position but produce no item. The +client follows the next cursor even when a page contains no items. + +Archive cursors use append-only round-index positions, not E3 IDs. New rounds do not shift an older +page. Requester matching is case-insensitive. Round-index schema 1 adds requester positions to the +existing JSON index. Startup backfills legacy indexes once and retains concurrent appends. A failed +backfill leaves the legacy version unchanged. Unsupported future versions fail explicitly. Existing +`/state/all` clients remain compatible. Countdown estimates use a shared chain clock and local +display ticks. Contracts still enforce voting deadlines. + ## Chain-Specific BFV Config The protocol release can carry more than one circuit artifact set. Current deployments use this diff --git a/crates/net/src/event_buffer/actor.rs b/crates/net/src/event_buffer/actor.rs index b0ce667afb..37384cdef3 100644 --- a/crates/net/src/event_buffer/actor.rs +++ b/crates/net/src/event_buffer/actor.rs @@ -25,6 +25,8 @@ pub const DEFAULT_MAX_BUFFERED_NET_BYTES: usize = 256 * 1024 * 1024; pub struct NetEventBufferHandle { readiness: oneshot::Receiver>, + #[cfg(test)] + actor: actix::Addr, } impl NetEventBufferHandle { @@ -78,7 +80,14 @@ impl NetEventBuffer { // Subscribe to InterfoldEvent on the bus bus.subscribe(EventType::SyncEnded, addr.clone().recipient()); - (output, NetEventBufferHandle { readiness }) + ( + output, + NetEventBufferHandle { + readiness, + #[cfg(test)] + actor: addr, + }, + ) } fn handle_interfold_event(&mut self, msg: InterfoldEvent) -> Result<()> { diff --git a/crates/net/src/event_buffer/tests.rs b/crates/net/src/event_buffer/tests.rs index ab81c58800..8c8601bef4 100644 --- a/crates/net/src/event_buffer/tests.rs +++ b/crates/net/src/event_buffer/tests.rs @@ -26,9 +26,26 @@ use libp2p::{ }; use tokio::{ sync::{broadcast, mpsc}, - time::{sleep, timeout}, + time::timeout, }; +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Message)] +#[rtype(result = "usize")] +struct BufferedEventCount; + +impl Handler for NetEventBuffer { + type Result = usize; + + fn handle(&mut self, _: BufferedEventCount, _: &mut actix::Context) -> usize { + match &self.state { + NetEventBufferState::Syncing { events, .. } => events.len(), + state => panic!("expected startup buffering, got {state:?}"), + } + } +} + fn sync_and_connection_control_events() -> Vec { let (command_tx, _command_rx) = mpsc::channel(1); vec![ @@ -96,24 +113,30 @@ async fn test_buffers_until_sync_ended() -> Result<()> { input_tx.send(event1.clone()).unwrap(); input_tx.send(event2.clone()).unwrap(); - // Give actor time to process - sleep(Duration::from_millis(10)).await; - - // Verify no events forwarded yet (should timeout) + // Wait for observable actor progress, then check that no event was forwarded. + timeout(DELIVERY_TIMEOUT, async { + while handle.actor.send(BufferedEventCount).await? != 2 { + tokio::task::yield_now().await; + } + Ok::<_, anyhow::Error>(()) + }) + .await + .context("network events did not reach the startup buffer")??; assert!( - timeout(Duration::from_millis(50), output_rx.recv()) - .await - .is_err(), + matches!( + output_rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), "Events should be buffered, not forwarded during sync" ); // Send SyncEnded event bus.publish_without_context(SyncEnded::new()).unwrap(); - handle.wait_until_running().await?; + timeout(DELIVERY_TIMEOUT, handle.wait_until_running()).await??; // Now buffered events should be forwarded - let received1 = output_rx.recv().await.unwrap(); - let received2 = output_rx.recv().await.unwrap(); + let received1 = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; + let received2 = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; assert!( matches!(received1, NetEvent::GossipData(GossipData::GossipBytes(ref bytes)) if bytes == &vec![1, 2, 3]) @@ -126,7 +149,7 @@ async fn test_buffers_until_sync_ended() -> Result<()> { let event3 = NetEvent::GossipData(GossipData::GossipBytes(vec![7, 8, 9])); input_tx.send(event3.clone()).unwrap(); - let received3 = tokio::time::timeout(tokio::time::Duration::from_millis(100), output_rx.recv()) + let received3 = timeout(DELIVERY_TIMEOUT, output_rx.recv()) .await .expect("Event should be forwarded immediately after sync") .unwrap(); @@ -150,7 +173,7 @@ async fn startup_buffer_overflow_fails_readiness_without_dropping_oldest() -> Re input_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![1])))?; input_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![2])))?; - let error = timeout(Duration::from_secs(1), handle.wait_until_running()) + let error = timeout(DELIVERY_TIMEOUT, handle.wait_until_running()) .await .context("network buffer did not report overflow")? .expect_err("overflow must fail startup readiness") @@ -176,7 +199,7 @@ async fn startup_buffer_enforces_estimated_payload_bytes() -> Result<()> { input_tx.send(event)?; - let error = timeout(Duration::from_secs(1), handle.wait_until_running()) + let error = timeout(DELIVERY_TIMEOUT, handle.wait_until_running()) .await .context("network buffer did not report byte overflow")? .expect_err("byte overflow must fail startup readiness") @@ -212,8 +235,8 @@ async fn sync_control_burst_does_not_lag_or_consume_the_application_buffer() -> event_tx.send(NetEvent::GossipData(GossipData::GossipBytes(vec![7])))?; bus.publish_without_context(SyncEnded::new())?; - handle.wait_until_running().await?; - let forwarded = timeout(Duration::from_secs(5), output_rx.recv()).await??; + timeout(DELIVERY_TIMEOUT, handle.wait_until_running()).await??; + let forwarded = timeout(DELIVERY_TIMEOUT, output_rx.recv()).await??; assert!(matches!( forwarded, NetEvent::GossipData(GossipData::GossipBytes(bytes)) if bytes == vec![7] diff --git a/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs b/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs index 1ad6670079..145896ea07 100644 --- a/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs +++ b/crates/zk-prover/tests/fold_accumulators_e2e_tests.rs @@ -128,13 +128,13 @@ fn c6_fold_total_slots_from_compiled_json() -> usize { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn c3_fold_compiled_abi_has_consistent_slot_count() { if !c3_fold_json_path().exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", c3_fold_json_path().display() ); - return; } let slots = c3_fold_total_slots_from_compiled_json(); assert!(slots > 0, "C3_SLOTS inferred from ABI should be positive"); @@ -143,13 +143,13 @@ fn c3_fold_compiled_abi_has_consistent_slot_count() { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn c6_fold_compiled_abi_has_consistent_slot_count() { if !c6_fold_json_path().exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", c6_fold_json_path().display() ); - return; } let slots = c6_fold_total_slots_from_compiled_json(); assert!(slots > 0, "C6 slots inferred from ABI should be positive"); @@ -158,6 +158,7 @@ fn c6_fold_compiled_abi_has_consistent_slot_count() { } #[test] +#[ignore = "requires compiled circuits; run pnpm rust:test:proofs"] fn node_fold_pipeline_compiled_json_load() { let mut missing = Vec::new(); for &c in NODE_FOLD_PIPELINE { @@ -167,11 +168,10 @@ fn node_fold_pipeline_compiled_json_load() { } } if !missing.is_empty() { - println!( - "skipping: missing compiled JSON(s) (run `pnpm build:circuits --group recursive_aggregation`): {:?}", + panic!( + "missing required test prerequisite: missing compiled JSON(s) (run `pnpm build:circuits --group recursive_aggregation`): {:?}", missing ); - return; } for &c in NODE_FOLD_PIPELINE { let path = recursive_aggregation_compiled_json_path(c); @@ -181,14 +181,16 @@ fn node_fold_pipeline_compiled_json_load() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn recursive_aggregation_default_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; if !c3_fold_json_path().exists() { - println!("skipping: {} not found", c3_fold_json_path().display()); - return; + panic!( + "missing required test prerequisite: {} not found", + c3_fold_json_path().display() + ); } let (backend, temp) = setup_test_prover(&bb).await; @@ -216,16 +218,18 @@ async fn recursive_aggregation_default_artifacts_staged() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn recursive_aggregation_c6_fold_kernel_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; let kernel_json = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../circuits/bin/recursive_aggregation/c6_fold_kernel/target/c6_fold_kernel.json"); if !kernel_json.exists() { - println!("skipping: {} not found", kernel_json.display()); - return; + panic!( + "missing required test prerequisite: {} not found", + kernel_json.display() + ); } let (backend, temp) = setup_test_prover(&bb).await; @@ -253,18 +257,17 @@ async fn recursive_aggregation_c6_fold_kernel_artifacts_staged() { } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn node_fold_pipeline_recursive_aggregation_artifacts_staged() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; let gate = recursive_aggregation_compiled_json_path(CircuitName::NodeFold); if !gate.exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", gate.display() ); - return; } let (backend, temp) = setup_test_prover(&bb).await; @@ -343,27 +346,26 @@ async fn setup_c3_fold_with_inner_share_encryption() -> Option<( } /// Expected C3 fold slot count when circuits are compiled for the minimum committee (N=3, T=1). -const MINIMUM_C3_FOLD_SLOTS: usize = 2; +const MINIMUM_C3_FOLD_SLOTS: usize = 6; /// Expected C6 fold slot count when circuits are compiled for the minimum committee (N=3, T=1). const MINIMUM_C6_FOLD_SLOTS: usize = 2; #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn c3_fold_sequential_proves_and_verifies() { let Some((_backend, _temp, prover, circuit, sample_a, sample_b, preset)) = setup_c3_fold_with_inner_share_encryption().await else { - println!("skipping: bb not found or prerequisites missing"); - return; + panic!("missing required test prerequisite: bb not found or prerequisites missing"); }; let total_slots = c3_fold_total_slots_from_compiled_json(); if total_slots != MINIMUM_C3_FOLD_SLOTS { - println!( - "skipping c3_fold_sequential_proves_and_verifies: circuits compiled for \ + panic!( + "c3_fold_sequential_proves_and_verifies: circuits compiled for \ non-minimum committee (total_slots={total_slots}, expected {MINIMUM_C3_FOLD_SLOTS}). \ Rebuild with `pnpm build:circuits --committee minimum` to run this test." ); - return; } let artifacts_dir = preset.artifacts_dir_for_committee("minimum"); @@ -460,22 +462,21 @@ async fn setup_c6_fold_with_inner_threshold_share_decryption() -> Option<( } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn c6_fold_sequential_proves_and_verifies() { let Some((_backend, _temp, prover, circuit, sample_a, sample_b, preset)) = setup_c6_fold_with_inner_threshold_share_decryption().await else { - println!("skipping: bb not found or prerequisites missing"); - return; + panic!("missing required test prerequisite: bb not found or prerequisites missing"); }; let total_slots = c6_fold_total_slots_from_compiled_json(); if total_slots != MINIMUM_C6_FOLD_SLOTS { - println!( - "skipping c6_fold_sequential_proves_and_verifies: circuits compiled for \ + panic!( + "c6_fold_sequential_proves_and_verifies: circuits compiled for \ non-minimum committee (total_slots={total_slots}, expected {MINIMUM_C6_FOLD_SLOTS}). \ Rebuild with `pnpm build:circuits --committee minimum` to run this test." ); - return; } let artifacts_dir = preset.artifacts_dir_for_committee("minimum"); let inner_e3_a = "e3-c6fold-inner-0"; diff --git a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs index 45d3c49f36..8bc414eef1 100644 --- a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs +++ b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs @@ -129,27 +129,24 @@ fn triplicate_honest_rows(mut d: ShareDecryptionCircuitData) -> ShareDecryptionC } #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:proofs"] async fn node_fold_correlated_sparse_self_slot_proves_and_verifies() { let Some(bb) = find_bb().await else { - println!("skipping: bb not found"); - return; + panic!("missing required test prerequisite: bb not found"); }; - if require_minimum_circuits().is_none() { - return; - } + require_minimum_circuits() + .expect("rebuild required circuits with pnpm build:circuits --committee minimum"); let gate = recursive_aggregation_compiled_json_path(CircuitName::NodeFold); if !gate.exists() { - println!( - "skipping: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", + panic!( + "missing required test prerequisite: {} not found (run `pnpm build:circuits --group recursive_aggregation`)", gate.display() ); - return; } if !c3_fold_json_path().exists() { - println!("skipping: c3_fold.json not found"); - return; + panic!("missing required test prerequisite: c3_fold.json not found"); } let committee = CiphernodesCommitteeSize::Minimum.values(); @@ -300,7 +297,10 @@ async fn node_fold_correlated_sparse_self_slot_proves_and_verifies() { let dkg_pk = fhe::bfv::PublicKey::new(&dkg_sk, &mut rng); let total_slots = c3_fold_total_slots_from_compiled_json(); - assert_eq!(total_slots, 6, "Micro / insecure preset uses 3×2 C3 slots"); + assert_eq!( + total_slots, 6, + "Minimum / insecure preset uses 3×2 C3 slots" + ); let slots_per_party = total_slots / committee.n; let own_party_id = 0usize; diff --git a/crates/zk-prover/tests/slashing_integration_tests.rs b/crates/zk-prover/tests/slashing_integration_tests.rs index f1213ef37b..6f25a12020 100644 --- a/crates/zk-prover/tests/slashing_integration_tests.rs +++ b/crates/zk-prover/tests/slashing_integration_tests.rs @@ -30,9 +30,9 @@ //! //! On-chain tests require: //! - `anvil` on PATH (from Foundry) -//! - Compiled Hardhat artifacts: `cd packages/interfold-contracts && npx hardhat compile` +//! - Compiled Hardhat artifacts: `pnpm evm:build` //! -//! Run with: `cargo test -p e3-zk-prover --test slashing_integration_tests` +//! Run with: `pnpm rust:test:slashing` mod common; @@ -51,7 +51,8 @@ use e3_events::{ SignedProofPayload, }; use e3_utils::utility_types::ArcBytes; -use std::path::PathBuf; +use serde::Deserialize; +use std::{collections::BTreeMap, path::PathBuf, sync::OnceLock}; // ── Contract ABI definitions (bytecodes loaded from Hardhat artifacts at runtime) ── @@ -76,6 +77,26 @@ sol! { function setBondingRegistry(address newBondingRegistry) external; function setCiphernodeRegistry(address newCiphernodeRegistry) external; function setInterfold(address newInterfold) external; + struct SlashProposal { + uint256 e3Id; + address operator; + bytes32 reason; + uint256 ticketAmount; + uint256 ciphernodeBondAmount; + bool executed; + bool appealed; + bool resolved; + bool appealUpheld; + uint256 proposedAt; + uint256 executableAt; + address proposer; + bytes32 proofHash; + bool proofVerified; + bool banNode; + bool affectsCommittee; + uint8 failureReason; + } + function getSlashProposal(uint256 proposalId) external view returns (SlashProposal memory); function totalProposals() external view returns (uint256); function isBanned(address node) external view returns (bool); @@ -87,6 +108,18 @@ sol! { error DuplicateEvidence(); } + #[sol(rpc)] + contract MockSlashingInterfold { + function snapshotDependencies(address manager, uint256 e3Id, uint256 lifecycleDeadline) external; + } + + #[sol(rpc)] + contract MockSlashingBondingRegistry { + function ticketPenaltyRequested() external view returns (uint256); + function bondPenaltyRequested() external view returns (uint256); + function openLocks() external view returns (uint256); + } + #[sol(rpc)] contract MockCiphernodeRegistry { function setCommitteeNodes(uint256 e3Id, address[] calldata nodes) external; @@ -97,61 +130,92 @@ sol! { // ── Helpers ── -/// No-op contract deployment bytecode. -/// -/// Deploys a contract whose runtime is a single STOP opcode. -/// All calls to this contract succeed with empty return data, making it -/// suitable as a mock for any interface that only has void-returning functions -/// (e.g., IInterfold.onE3Failed). -const NOOP_DEPLOY_BYTECODE: &[u8] = &[ - 0x60, 0x01, // PUSH1 0x01 (runtime size) - 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) - 0x60, 0x00, // PUSH1 0x00 (memory destination) - 0x39, // CODECOPY - 0x60, 0x01, // PUSH1 0x01 (return size) - 0x60, 0x00, // PUSH1 0x00 (return offset) - 0xf3, // RETURN - 0x00, // -- runtime: STOP -- -]; - -/// Mock contract that returns 32 zero bytes for any call. -/// -/// EVM memory is zero-initialized, so `RETURN(0x00, 0x20)` returns 32 zero bytes. -/// Suitable as a mock for interfaces that return a single `uint256` -/// (e.g., `IBondingRegistry.slashTicketBalance` returns `uint256`). -const RETURNER_DEPLOY_BYTECODE: &[u8] = &[ - 0x60, 0x05, // PUSH1 0x05 (runtime size) - 0x60, 0x0c, // PUSH1 0x0c (offset of runtime in init code) - 0x60, 0x00, // PUSH1 0x00 (memory destination) - 0x39, // CODECOPY - 0x60, 0x05, // PUSH1 0x05 (return size) - 0x60, 0x00, // PUSH1 0x00 (return offset) - 0xf3, // RETURN - // -- runtime: return 32 zero bytes -- - 0x60, 0x20, // PUSH1 0x20 - 0x60, 0x00, // PUSH1 0x00 - 0xf3, // RETURN -]; - -fn contracts_artifacts_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) +#[derive(Deserialize)] +struct LinkReference { + start: usize, + length: usize, +} + +#[derive(Deserialize)] +struct ContractArtifact { + bytecode: String, + #[serde(rename = "linkReferences", default)] + links: BTreeMap>>, +} + +struct SlashingArtifacts { + manager: ContractArtifact, + registry: Vec, + evidence_library: Vec, + interfold: Vec, + bonding: Vec, +} + +fn read_artifact(subpath: &str) -> ContractArtifact { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../packages/interfold-contracts/artifacts/contracts") + .join(subpath); + let raw = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "Cannot read {}: {error}. Run pnpm evm:build.", + path.display() + ) + }); + serde_json::from_str(&raw) + .unwrap_or_else(|error| panic!("Invalid artifact {}: {error}", path.display())) } -fn read_artifact_bytecode(subpath: &str) -> Option> { - let path = contracts_artifacts_dir().join(subpath); - let json_str = std::fs::read_to_string(&path).ok()?; - let json: serde_json::Value = serde_json::from_str(&json_str).ok()?; - let hex_str = json["bytecode"].as_str()?; - let clean = hex_str.strip_prefix("0x").unwrap_or(hex_str); - hex::decode(clean).ok() +fn decode_bytecode(artifact: &ContractArtifact) -> Vec { + let bytes = hex::decode( + artifact + .bytecode + .strip_prefix("0x") + .unwrap_or(&artifact.bytecode), + ) + .expect("Artifact bytecode must be linked hexadecimal"); + assert!(!bytes.is_empty(), "Artifact deployment bytecode is empty"); + bytes +} + +fn load_slashing_artifacts() -> &'static SlashingArtifacts { + static ARTIFACTS: OnceLock = OnceLock::new(); + ARTIFACTS.get_or_init(|| SlashingArtifacts { + manager: read_artifact("slashing/SlashingManager.sol/SlashingManager.json"), + registry: decode_bytecode(&read_artifact( + "test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json", + )), + evidence_library: decode_bytecode(&read_artifact( + "lib/SlashingEvidenceLib.sol/SlashingEvidenceLib.json", + )), + interfold: decode_bytecode(&read_artifact( + "test/MockSlashingInterfold.sol/MockSlashingInterfold.json", + )), + bonding: decode_bytecode(&read_artifact( + "test/MockSlashingBondingRegistry.sol/MockSlashingBondingRegistry.json", + )), + }) } -/// Load contract bytecodes, returning None if any are missing. -fn load_slashing_artifacts() -> Option<(Vec, Vec)> { - let sm = read_artifact_bytecode("slashing/SlashingManager.sol/SlashingManager.json")?; - let mr = read_artifact_bytecode("test/MockCiphernodeRegistry.sol/MockCiphernodeRegistry.json")?; - Some((sm, mr)) +fn link_manager_bytecode(artifact: &ContractArtifact, library: Address) -> Vec { + let mut bytecode = artifact + .bytecode + .strip_prefix("0x") + .unwrap_or(&artifact.bytecode) + .to_owned(); + for libraries in artifact.links.values() { + for (name, references) in libraries { + assert_eq!(name, "SlashingEvidenceLib", "Unexpected linked library"); + for reference in references { + assert_eq!( + reference.length, 20, + "A linked address must occupy 20 bytes" + ); + let start = reference.start * 2; + bytecode.replace_range(start..start + 40, &hex::encode(library)); + } + } + } + hex::decode(bytecode).expect("SlashingManager bytecode must be fully linked") } /// Deploy a contract on the connected provider. @@ -818,28 +882,27 @@ fn test_attestation_evidence_encoding() { // ════════════════════════════════════════════════════════════════════════════ /// Deploy SlashingManager and configure dependencies. -/// Returns (SlashingManager contract instance, admin address). +/// Returns the manager and the collateral-call recorder addresses. async fn deploy_and_configure( provider: &impl Provider, - sm_bytecode: &[u8], + sm_artifact: &ContractArtifact, mock_registry_addr: Address, ) -> (Address, Address) { let accounts = provider.get_accounts().await.unwrap(); let admin = accounts[0]; - // Deploy noop for interfold (void functions) - let noop_addr = deploy_contract(provider, NOOP_DEPLOY_BYTECODE, &[]).await; - // Deploy returner for bondingRegistry (slashTicketBalance returns uint256) - let returner_addr = deploy_contract(provider, RETURNER_DEPLOY_BYTECODE, &[]).await; - - // Deploy SlashingManager(initialDelay, admin) — use 0 delay for local tests + let artifacts = load_slashing_artifacts(); + let interfold_addr = deploy_contract(provider, &artifacts.interfold, &[]).await; + let bonding_addr = deploy_contract(provider, &artifacts.bonding, &[]).await; + let library_addr = deploy_contract(provider, &artifacts.evidence_library, &[]).await; + let bytecode = link_manager_bytecode(sm_artifact, library_addr); let sm_args = (0u64, admin).abi_encode(); - let sm_addr = deploy_contract(provider, sm_bytecode, &sm_args).await; + let sm_addr = deploy_contract(provider, &bytecode, &sm_args).await; // Configure dependencies via admin functions let slashing_mgr = SlashingManager::new(sm_addr, provider); slashing_mgr - .setBondingRegistry(returner_addr) + .setBondingRegistry(bonding_addr) .send() .await .unwrap() @@ -855,7 +918,7 @@ async fn deploy_and_configure( .await .unwrap(); slashing_mgr - .setInterfold(noop_addr) + .setInterfold(interfold_addr) .send() .await .unwrap() @@ -863,7 +926,20 @@ async fn deploy_and_configure( .await .unwrap(); - (sm_addr, admin) + // Each test uses one of these E3 IDs. Snapshot the request-time dependencies. + let interfold = MockSlashingInterfold::new(interfold_addr, provider); + let (_, deadline) = current_vote_window(provider).await; + for e3_id in [7u64, 42u64] { + interfold + .snapshotDependencies(sm_addr, U256::from(e3_id), deadline) + .send() + .await + .expect("Snapshot dependencies transaction") + .get_receipt() + .await + .expect("Snapshot dependencies receipt"); + } + (sm_addr, bonding_addr) } /// **Lane A attestation flow**: 3 committee members vote on a fault, quorum @@ -872,22 +948,14 @@ async fn deploy_and_configure( /// Proves the complete Rust→Solidity attestation signing pipeline works: /// vote_digest → sign_message_sync → abi.encode evidence → proposeSlash → _verifyAttestationEvidence #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_valid_attestation_executes_slash() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!( - "skipping: contract artifacts not found \ - (run `npx hardhat compile` in packages/interfold-contracts)" - ); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -907,7 +975,8 @@ async fn test_onchain_valid_attestation_executes_slash() { let mock_registry = MockCiphernodeRegistry::new(mock_registry_addr, &provider); // Deploy and configure SlashingManager - let (sm_addr, _admin) = deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; + let (sm_addr, _bonding) = + deploy_and_configure(&provider, &sm_bytecode, mock_registry_addr).await; let slashing_mgr = SlashingManager::new(sm_addr, &provider); let e3_id: u64 = 42; @@ -1050,7 +1119,7 @@ async fn test_onchain_valid_attestation_executes_slash() { "proposeSlash should succeed with valid attestation quorum" ); - // Verify proposal was created and executed + // Verify proposal creation and execution independently. let proposals_after = slashing_mgr .totalProposals() .call() @@ -1062,26 +1131,39 @@ async fn test_onchain_valid_attestation_executes_slash() { "should have 1 proposal after slash" ); - println!( - "PASS: valid attestation quorum → slash executed — attestation signing pipeline verified" + let proposal = slashing_mgr + .getSlashProposal(U256::ZERO) + .call() + .await + .unwrap(); + assert!(proposal.executed, "The proposal must be executed"); + assert!(proposal.proofVerified, "The attestation must be verified"); + assert_eq!(proposal.e3Id, U256::from(e3_id)); + assert_eq!(proposal.operator, operator_addr); + let bonding = MockSlashingBondingRegistry::new(_bonding, &provider); + assert_eq!( + bonding.ticketPenaltyRequested().call().await.unwrap(), + proposal.ticketAmount ); + assert_eq!( + bonding.bondPenaltyRequested().call().await.unwrap(), + proposal.ciphernodeBondAmount + ); + assert_eq!(bonding.openLocks().call().await.unwrap(), U256::ZERO); + + println!("PASS: attestation verified and proposal executed against collateral-call mocks"); } /// Tests that insufficient attestations (below threshold M) cause revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_insufficient_attestations_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1198,19 +1280,14 @@ async fn test_onchain_insufficient_attestations_reverts() { /// Tests that a voter not in the committee causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_voter_not_in_committee_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1321,19 +1398,14 @@ async fn test_onchain_voter_not_in_committee_reverts() { /// Tests that an invalid vote signature (signed by wrong key) causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_invalid_vote_signature_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1456,19 +1528,14 @@ async fn test_onchain_invalid_vote_signature_reverts() { /// The contract requires voters in strictly ascending address order to prevent /// the same voter from being counted twice. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_duplicate_voter_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1583,19 +1650,14 @@ async fn test_onchain_duplicate_voter_reverts() { /// Tests that replaying the same evidence causes revert. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_duplicate_evidence_reverts() { if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!("skipping: contract artifacts not found"); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); @@ -1742,26 +1804,18 @@ async fn test_onchain_duplicate_evidence_reverts() { /// must produce calldata that `SlashingManager._verifyAttestationEvidence` /// accepts. This is the canonical "actor → Solidity" end-to-end test. #[tokio::test] +#[ignore = "requires prepared integration artifacts; run pnpm rust:test:slashing"] async fn test_onchain_actor_signed_vote_accepted() { use e3_events::{AccusationOutcome, AccusationQuorumReached, AccusationVote, ProofType}; use e3_evm::encode_attestation_evidence; use e3_slashing::AccusationManager; if !find_anvil().await { - println!("skipping: anvil not found on PATH"); - return; + panic!("missing required test prerequisite: anvil not found on PATH"); } - let (sm_bytecode, mr_bytecode) = match load_slashing_artifacts() { - Some(artifacts) => artifacts, - None => { - println!( - "skipping: contract artifacts not found \ - (run `npx hardhat compile` in packages/interfold-contracts)" - ); - return; - } - }; + let artifacts = load_slashing_artifacts(); + let (sm_bytecode, mr_bytecode) = (&artifacts.manager, &artifacts.registry); let provider = ProviderBuilder::new().connect_anvil_with_wallet(); let chain_id = provider.get_chain_id().await.unwrap(); diff --git a/examples/CRISP/client/package.json b/examples/CRISP/client/package.json index 95e6518155..c09283b481 100644 --- a/examples/CRISP/client/package.json +++ b/examples/CRISP/client/package.json @@ -9,6 +9,7 @@ }, "homepage": "https://github.com/gnosisguild/CRISP", "scripts": { + "test": "vitest --run --config vitest.config.ts", "cli": "pnpm sh ./scripts/cli.sh", "dev": "vite --no-open --host", "dev-static": "NO_HOT=1 vite --no-open --host", @@ -39,6 +40,9 @@ "wagmi": "^2.14.16" }, "devDependencies": { + "vitest": "1.6.1", + "react-test-renderer": "18.3.1", + "@types/react-test-renderer": "^18.3.0", "@tailwindcss/typography": "^0.5.12", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/examples/CRISP/client/src/components/CircularTiles.tsx b/examples/CRISP/client/src/components/CircularTiles.tsx deleted file mode 100644 index a316fc5f30..0000000000 --- a/examples/CRISP/client/src/components/CircularTiles.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// 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 { memo, useState } from 'react' -import CircularTile from './CircularTile' - -const generateRotations = (count: number) => [...Array(count)].map(() => [0, 90, 180, 270][Math.floor(Math.random() * 4)]) - -const CircularTiles = ({ count = 1, className }: { count?: number; className?: string }) => { - const [rotations, setRotations] = useState(() => generateRotations(count)) - const [renderedCount, setRenderedCount] = useState(count) - - // Re-roll the rotations when the number of tiles changes, adjusting state - // during render rather than in an effect. - if (renderedCount !== count) { - setRenderedCount(count) - setRotations(generateRotations(count)) - } - - return ( - <> - {rotations.map((rotation, index) => ( - - ))} - - ) -} - -export default memo(CircularTiles) diff --git a/examples/CRISP/client/src/components/CountdownTime.tsx b/examples/CRISP/client/src/components/CountdownTime.tsx index 4215671061..b4718203e6 100644 --- a/examples/CRISP/client/src/components/CountdownTime.tsx +++ b/examples/CRISP/client/src/components/CountdownTime.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react' import { usePublicClient } from 'wagmi' -import LoadingAnimation from '@/components/LoadingAnimation' +import { subscribeEstimatedChainTime } from '@/utils/estimated-chain-clock' interface CountdownTimerProps { endTime: Date @@ -22,47 +22,29 @@ type RemainingTime = { const CountdownTimer: React.FC = ({ endTime }) => { const client = usePublicClient() const [remainingTime, setRemainingTime] = useState(null) - const [loading, setLoading] = useState(true) + const endTimeMs = endTime.getTime() - useEffect(() => { - const timer = setInterval(async () => { - // Use chain block timestamp so countdown matches when poll actually ends (block.timestamp > end_time) - let nowMs: number - if (client) { - try { - const block = await client.getBlock() - nowMs = Number(block.timestamp) * 1000 - } catch { - nowMs = Date.now() - } - } else { - nowMs = Date.now() - } - const difference = endTime.getTime() - nowMs - if (difference <= 0) { - clearInterval(timer) - setLoading(false) - setRemainingTime({ days: '0', hours: '0', minutes: '0', seconds: '0' }) - return - } - - const days = Math.floor(difference / (1000 * 60 * 60 * 24)).toString() - const hours = Math.floor((difference / (1000 * 60 * 60)) % 24).toString() - const minutes = Math.floor((difference / 1000 / 60) % 60).toString() - const seconds = Math.floor((difference / 1000) % 60).toString() - setRemainingTime({ days, hours, minutes, seconds }) - setLoading(false) - }, 1000) - - return () => clearInterval(timer) - }, [endTime, client]) + useEffect( + () => + subscribeEstimatedChainTime(client, (estimatedNowMs) => { + const difference = Math.max(0, endTimeMs - estimatedNowMs) + setRemainingTime({ + days: Math.floor(difference / 86_400_000).toString(), + hours: Math.floor((difference / 3_600_000) % 24).toString(), + minutes: Math.floor((difference / 60_000) % 60).toString(), + seconds: Math.floor((difference / 1_000) % 60).toString(), + }) + }), + [endTimeMs, client], + ) return (
-

Poll ends in:

+

+ Poll ends in: +

- {loading && } - {!loading && remainingTime && ( + {remainingTime && (

{remainingTime.days} diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx index 85d325ee26..8f3d7069be 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx @@ -68,6 +68,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { getRoundStateLite: getRoundStateLiteRequest, getWebResultByRound, getWebResult, + getArchivePage, getCurrentRound, broadcastVote, getVoteAvailability, @@ -147,17 +148,17 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const currentResult = await getWebResultByRound(currentRound.id) const currentHasTally = !!(currentResult && Array.isArray(currentResult.tally) && currentResult.tally.length > 0) if (!currentHasTally) { - const all = await getWebResult() - const latestWithTally = (all ?? []) - .filter((r) => Array.isArray(r.tally) && r.tally.length > 0) - .sort((a, b) => { - const aId = BigInt(a.round_id) - const bId = BigInt(b.round_id) - return aId === bId ? 0 : aId < bId ? 1 : -1 - })[0] - if (latestWithTally && latestWithTally.round_id !== currentRound.id) { - fallbackRoundId = latestWithTally.round_id - } + let cursor: string | undefined + do { + const page = await getArchivePage(cursor) + const latestWithTally = page?.items.find((round) => Array.isArray(round.tally) && round.tally.length > 0) + if (latestWithTally) { + if (latestWithTally.round_id !== currentRound.id) fallbackRoundId = latestWithTally.round_id + break + } + if (!page?.next_cursor || page.next_cursor === cursor) break + cursor = page.next_cursor + } while (cursor) } } @@ -169,13 +170,16 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } } - const getRoundStateLite = async (roundId: string) => { - const fetchedRoundState = await getRoundStateLiteRequest(roundId) + const getRoundStateLite = useCallback( + async (roundId: string) => { + const fetchedRoundState = await getRoundStateLiteRequest(roundId) - if (fetchedRoundState) { - applyRoundState(fetchedRoundState) - } - } + if (fetchedRoundState) { + applyRoundState(fetchedRoundState) + } + }, + [getRoundStateLiteRequest, applyRoundState], + ) const getRoundStateLiteRequestRef = useRef(getRoundStateLiteRequest) useEffect(() => { @@ -193,6 +197,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { let cancelled = false let timer: ReturnType | null = null let delay = ROUND_POLL_INITIAL_MS + let inFlight = false function schedule(wait = delay) { if (cancelled || document.hidden) return @@ -203,42 +208,52 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } async function poll() { - if (cancelled) return - - const currentRound = await getCurrentRoundRef.current() - if (cancelled) return - - if (currentRound) { - const fetched = await getRoundStateLiteRequestRef.current(currentRound.id) + if (cancelled || inFlight) return + inFlight = true + try { + const currentRound = await getCurrentRoundRef.current() if (cancelled) return - // The current-round pointer can change while its state is in flight. Confirm it again - // before committing either value, or this effect stops polling on a stale round. - const confirmedRound = await getCurrentRoundRef.current() - if (cancelled) return - if (!confirmedRound || confirmedRound.id !== currentRound.id) { - schedule(1_000) + if (currentRound) { + const fetched = await getRoundStateLiteRequestRef.current(currentRound.id) + if (cancelled) return + + // The current-round pointer can change while its state is in flight. Confirm it again + // before committing either value, or this effect stops polling on a stale round. + const confirmedRound = await getCurrentRoundRef.current() + if (cancelled) return + if (!confirmedRound || confirmedRound.id !== currentRound.id) { + schedule(1_000) + return + } + + // Fetch the state before storing the round ID. Storing the ID reruns this + // effect and cancels the current request. If we store it first, a round + // that becomes active after page load can discard its successful state + // response and leave the page in the preparing state permanently. + setCurrentRoundId(currentRound.id) + setDisplayedRoundIsFallback(false) + + if (fetched) { + applyRoundState(fetched) + setPendingCurrentRoundId(null) + } else { + setPendingCurrentRoundId(currentRound.id) + } return } - // Fetch the state before storing the round ID. Storing the ID reruns this - // effect and cancels the current request. If we store it first, a round - // that becomes active after page load can discard its successful state - // response and leave the page in the preparing state permanently. - setCurrentRoundId(currentRound.id) - setDisplayedRoundIsFallback(false) - - if (fetched) { - applyRoundState(fetched) - setPendingCurrentRoundId(null) - } else { - setPendingCurrentRoundId(currentRound.id) + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + } catch (error) { + if (!cancelled) { + handleGenericError('Round polling failed', error as Error) + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() } - return + } finally { + inFlight = false } - - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() } function resumeWhenVisible() { @@ -263,6 +278,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { let cancelled = false let timer: ReturnType | null = null let delay = ROUND_POLL_INITIAL_MS + let inFlight = false function schedule() { if (cancelled || document.hidden) return @@ -273,40 +289,50 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } async function poll() { - if (cancelled) return - - const currentRound = await getCurrentRoundRef.current() - if (cancelled) return - if (currentRound && currentRound.id !== pendingRoundId) { - // A newer round replaced the one whose key we were waiting for. Reset discovery instead - // of keeping the page attached to an old round that may never become readable. - setPendingCurrentRoundId(null) - setCurrentRoundId(null) - return - } - - const fetched = await getRoundStateLiteRequestRef.current(pendingRoundId) - if (cancelled) return - if (fetched) { - const confirmedRound = await getCurrentRoundRef.current() + if (cancelled || inFlight) return + inFlight = true + try { + const currentRound = await getCurrentRoundRef.current() if (cancelled) return - if (!confirmedRound) { - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() + if (currentRound && currentRound.id !== pendingRoundId) { + // A newer round replaced the one whose key we were waiting for. Reset discovery instead + // of keeping the page attached to an old round that may never become readable. + setPendingCurrentRoundId(null) + setCurrentRoundId(null) return } - if (confirmedRound.id !== pendingRoundId) { + + const fetched = await getRoundStateLiteRequestRef.current(pendingRoundId) + if (cancelled) return + if (fetched) { + const confirmedRound = await getCurrentRoundRef.current() + if (cancelled) return + if (!confirmedRound) { + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + return + } + if (confirmedRound.id !== pendingRoundId) { + setPendingCurrentRoundId(null) + setCurrentRoundId(null) + return + } + applyRoundState(fetched) setPendingCurrentRoundId(null) - setCurrentRoundId(null) return } - applyRoundState(fetched) - setPendingCurrentRoundId(null) - return - } - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + } catch (error) { + if (!cancelled) { + handleGenericError('Round polling failed', error as Error) + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() + } + } finally { + inFlight = false + } } function resumeWhenVisible() { @@ -326,7 +352,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const getPastPolls = async () => { try { - const result = await getWebResult() + const result = (await getArchivePage())?.items if (result) { const convertedPolls = convertPollData(result) setPastPolls(convertedPolls) diff --git a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx index 5aa6a836dc..e1c8b1e115 100644 --- a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx +++ b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import axios, { AxiosRequestConfig, Method } from 'axios' import { handleGenericError } from '@/utils/handle-generic-error' @@ -14,26 +14,34 @@ type FetchConfig = AxiosRequestConfig & { export const useApi = () => { const [isLoading, setIsLoading] = useState(false) - - const fetchData = async ( - url: string, - method: Method = 'get', - data?: U, - config?: FetchConfig, - ): Promise => { - setIsLoading(true) - const { suppressNotFound = false, ...axiosConfig } = config ?? {} - try { - const response = method === 'get' ? await axios.get(`${url}`, axiosConfig) : await axios.post(`${url}`, data, axiosConfig) - return response.data - } catch (error) { - if (suppressNotFound && axios.isAxiosError(error) && error.response?.status === 404) return undefined - handleGenericError(`API Error - ${url}`, error as Error) - } finally { - setIsLoading(false) + const pending = useRef(0) + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false } - return undefined - } + }, []) + + const fetchData = useCallback( + async (url: string, method: Method = 'get', data?: U, config?: FetchConfig): Promise => { + pending.current += 1 + if (mounted.current) setIsLoading(true) + const { suppressNotFound = false, ...axiosConfig } = config ?? {} + try { + const response = await axios.request({ ...axiosConfig, url, method, data }) + return response.data + } catch (error) { + if (suppressNotFound && axios.isAxiosError(error) && error.response?.status === 404) return undefined + handleGenericError(`API Error - ${url}`, error as Error) + throw error + } finally { + pending.current -= 1 + if (mounted.current) setIsLoading(pending.current > 0) + } + }, + [], + ) return { fetchData, isLoading } } diff --git a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts index 8452baa1bd..4c34eab382 100644 --- a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts +++ b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts @@ -15,9 +15,10 @@ import { VoteStatusResponse, } from '@/model/vote.model' import { useApi } from '../generic/useFetchApi' -import { PollRequestResult } from '@/model/poll.model' +import { ArchivePage, PollRequestResult } from '@/model/poll.model' import { ROUND_REQUESTERS } from '@/utils/constants' import axios from 'axios' +import { useMemo } from 'react' const INTERFOLD_API = import.meta.env.VITE_INTERFOLD_API @@ -28,6 +29,7 @@ const InterfoldEndpoints = { GetRoundStateLite: `${INTERFOLD_API}/state/lite`, GetWebResult: `${INTERFOLD_API}/state/result`, GetWebAllResult: `${INTERFOLD_API}/state/all`, + GetArchivePage: `${INTERFOLD_API}/state/archive`, BroadcastVote: `${INTERFOLD_API}/voting/broadcast`, GetVoteAvailability: `${INTERFOLD_API}/voting/availability`, GetVoteStatus: `${INTERFOLD_API}/voting/status`, @@ -35,54 +37,70 @@ const InterfoldEndpoints = { GetMerkleLeaves: `${INTERFOLD_API}/state/token-holders`, } as const +const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetVoteAvailability, GetRoundStateLite, GetWebResult, GetVoteStatus } = + InterfoldEndpoints + export const useInterfoldServer = () => { - const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetVoteAvailability, GetRoundStateLite, GetWebResult, GetVoteStatus } = - InterfoldEndpoints const { fetchData, isLoading } = useApi() - const getCurrentRound = () => - fetchData(GetCurrentRound, 'post', { requesters: ROUND_REQUESTERS }, { suppressNotFound: true }) - const getRoundStateLite = (round_id: string) => - fetchData(GetRoundStateLite, 'post', { round_id }, { suppressNotFound: true }) - const getVoteAvailability = async (jobId: string): Promise => { - const url = `${GetVoteAvailability}/${encodeURIComponent(jobId)}` - try { - return (await axios.get(url)).data - } catch (error) { - // A server replacement can legitimately lose its local job database. Tell the caller this - // job is gone so it can clear localStorage and submit again. Other failures are transient. - if (axios.isAxiosError(error) && error.response?.status === 404) return null - handleGenericError(`API Error - ${url}`, error as Error) - return undefined + const endpoints = useMemo(() => { + const getCurrentRound = () => + fetchData( + GetCurrentRound, + 'post', + { requesters: ROUND_REQUESTERS }, + { suppressNotFound: true }, + ) + const getRoundStateLite = (round_id: string) => + fetchData(GetRoundStateLite, 'post', { round_id }, { suppressNotFound: true }) + const getVoteAvailability = async (jobId: string): Promise => { + const url = `${GetVoteAvailability}/${encodeURIComponent(jobId)}` + try { + return (await axios.get(url)).data + } catch (error) { + // A server replacement can legitimately lose its local job database. Tell the caller this + // job is gone so it can clear localStorage and submit again. Other failures are transient. + if (axios.isAxiosError(error) && error.response?.status === 404) return null + handleGenericError(`API Error - ${url}`, error as Error) + return undefined + } + } + const broadcastVote = async ( + vote: BroadcastVoteRequest, + onJobCreated?: (jobId: string) => void, + ): Promise => { + const initial = await fetchData(BroadcastVote, 'post', vote) + if (!initial) return undefined + if (initial.job_id) onJobCreated?.(initial.job_id) + return initial } - } - const broadcastVote = async ( - vote: BroadcastVoteRequest, - onJobCreated?: (jobId: string) => void, - ): Promise => { - const initial = await fetchData(BroadcastVote, 'post', vote) - if (!initial) return undefined - if (initial.job_id) onJobCreated?.(initial.job_id) - return initial - } - const getWebResult = () => - fetchData(GetWebAllResult, 'post', { requesters: ROUND_REQUESTERS }) - const getWebResultByRound = (round_id: string) => fetchData(GetWebResult, 'post', { round_id }) - const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) - const getEligibleVoters = (round_id: string) => - fetchData(InterfoldEndpoints.GetEligibleVoters, 'post', { round_id }) - const getMerkleLeaves = (round_id: string) => - fetchData(InterfoldEndpoints.GetMerkleLeaves, 'post', { round_id }) + const getWebResult = () => + fetchData(GetWebAllResult, 'post', { requesters: ROUND_REQUESTERS }) + const getArchivePage = (cursor?: string) => + fetchData(InterfoldEndpoints.GetArchivePage, 'post', { + requesters: ROUND_REQUESTERS, + cursor, + limit: 12, + }) + const getWebResultByRound = (round_id: string) => + fetchData(GetWebResult, 'post', { round_id }, { suppressNotFound: true }) + const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) + const getEligibleVoters = (round_id: string) => + fetchData(InterfoldEndpoints.GetEligibleVoters, 'post', { round_id }) + const getMerkleLeaves = (round_id: string) => + fetchData(InterfoldEndpoints.GetMerkleLeaves, 'post', { round_id }) - return { - isLoading, - getWebResultByRound, - getWebResult, - getCurrentRound, - getRoundStateLite, - broadcastVote, - getVoteAvailability, - getVoteStatus, - getEligibleVoters, - getMerkleLeaves, - } + return { + getWebResultByRound, + getWebResult, + getArchivePage, + getCurrentRound, + getRoundStateLite, + broadcastVote, + getVoteAvailability, + getVoteStatus, + getEligibleVoters, + getMerkleLeaves, + } + }, [fetchData]) + return { isLoading, ...endpoints } } diff --git a/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts b/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts new file mode 100644 index 0000000000..8bbc25ead4 --- /dev/null +++ b/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ArchivePage, PollRequestResult } from '@/model/poll.model' + +type FetchPage = (cursor?: string) => Promise + +export function useArchivePolls(fetchPage: FetchPage) { + const [items, setItems] = useState([]) + const [hasMore, setHasMore] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const state = useRef({ pending: false, cursor: undefined as string | undefined, done: false }) + + const loadMore = useCallback(async () => { + const current = state.current + if (current.pending || current.done) return + current.pending = true + setIsLoading(true) + setError(null) + try { + const page = await fetchPage(current.cursor) + if (state.current !== current) return + if (!page) throw new Error('Archive response is missing') + if (page.next_cursor !== null && page.next_cursor === current.cursor) throw new Error('Archive cursor did not advance') + setItems((previous) => { + const rows = new Map(previous.map((item) => [item.round_id, item])) + for (const item of page.items) rows.set(item.round_id, item) + return [...rows.values()] + }) + current.cursor = page.next_cursor ?? undefined + current.done = page.next_cursor === null + setHasMore(!current.done) + } catch { + if (state.current === current) setError('Could not load polls. Try again.') + } finally { + current.pending = false + if (state.current === current) setIsLoading(false) + } + }, [fetchPage]) + + useEffect(() => { + state.current = { pending: false, cursor: undefined, done: false } + // Clear the old query result when the data source changes. + // eslint-disable-next-line react-hooks/set-state-in-effect + setItems([]) + setHasMore(true) + void loadMore() + return () => { + state.current = { pending: false, cursor: undefined, done: true } + } + }, [loadMore]) + + return { items, hasMore, isLoading, error, loadMore } +} diff --git a/examples/CRISP/client/src/model/poll.model.ts b/examples/CRISP/client/src/model/poll.model.ts index e43f5617e2..549d418b71 100644 --- a/examples/CRISP/client/src/model/poll.model.ts +++ b/examples/CRISP/client/src/model/poll.model.ts @@ -28,6 +28,11 @@ export interface PollRequestResult { total_votes: number } +export interface ArchivePage { + items: PollRequestResult[] + next_cursor: string | null +} + export interface Poll { value: number checked: boolean diff --git a/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx b/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx index 31d7a0603e..1d1bc5c48b 100644 --- a/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx +++ b/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx @@ -4,59 +4,34 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo } from 'react' import PollCard from '@/components/Cards/PollCard' import { PollResult } from '@/model/poll.model' import LoadingAnimation from '@/components/LoadingAnimation' import { useVoteManagementContext } from '@/context/voteManagement' import { EditorialShell } from '@/design/Editorial' -import { debounce } from '@/utils/methods' +import { convertPollData } from '@/utils/methods' +import { useInterfoldServer } from '@/hooks/interfold/useInterfoldServer' +import { useArchivePolls } from '@/hooks/voting/useArchivePolls' const AllPolls: React.FC = () => { - const { votingRound, pastPolls, getPastPolls, isLoading } = useVoteManagementContext() - const [page, setPage] = useState(0) - const [loadingMore, setLoadingMore] = useState(false) - - const loadMorePolls = useCallback(() => { - if (loadingMore || isLoading) return - setLoadingMore(true) - setTimeout(() => { - setPage((prevPage) => prevPage + 1) - window.scrollTo({ - top: document.documentElement.scrollTop - 150, - behavior: 'smooth', - }) - setLoadingMore(false) - }, 1000) - }, [loadingMore, isLoading]) + const { setPastPolls } = useVoteManagementContext() + const { getArchivePage } = useInterfoldServer() + const { items, hasMore, isLoading, error, loadMore } = useArchivePolls(getArchivePage) + const visiblePolls = useMemo(() => convertPollData(items), [items]) useEffect(() => { - if (votingRound && votingRound?.pk_bytes) { - const fetchPastPolls = async () => { - await getPastPolls() - } - fetchPastPolls() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [votingRound]) - - const visiblePolls = useMemo(() => pastPolls.slice(0, (page + 1) * 12), [page, pastPolls]) - - const handleScroll = useMemo( - () => - debounce(() => { - const { scrollTop, clientHeight, scrollHeight } = document.documentElement - if (scrollTop + clientHeight >= scrollHeight && !loadingMore && pastPolls.length > visiblePolls.length) { - loadMorePolls() - } - }, 200), - [loadMorePolls, loadingMore, pastPolls.length, visiblePolls.length], - ) + setPastPolls(visiblePolls) + }, [visiblePolls, setPastPolls]) useEffect(() => { - window.addEventListener('scroll', handleScroll) + const handleScroll = () => { + const { scrollTop, clientHeight, scrollHeight } = document.documentElement + if (scrollTop + clientHeight >= scrollHeight - 100 && hasMore && !isLoading && !error) void loadMore() + } + window.addEventListener('scroll', handleScroll, { passive: true }) return () => window.removeEventListener('scroll', handleScroll) - }, [handleScroll]) + }, [hasMore, isLoading, error, loadMore]) return ( @@ -70,7 +45,7 @@ const AllPolls: React.FC = () => {

)} - {!pastPolls.length && !isLoading &&

There are no polls yet.

} + {!visiblePolls.length && !isLoading && !error && !hasMore &&

There are no polls yet.

} {visiblePolls.length > 0 && (
{visiblePolls.map((pollResult: PollResult, index: number) => { @@ -86,10 +61,11 @@ const AllPolls: React.FC = () => { })}
)} - {loadingMore && ( -
- -
+ {error &&

{error}

} + {hasMore && !isLoading && ( + )} diff --git a/examples/CRISP/client/src/pages/PollResult/PollResult.tsx b/examples/CRISP/client/src/pages/PollResult/PollResult.tsx index 6bae28fd44..627c95b984 100644 --- a/examples/CRISP/client/src/pages/PollResult/PollResult.tsx +++ b/examples/CRISP/client/src/pages/PollResult/PollResult.tsx @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { Fragment, useEffect, useMemo } from 'react' +import React, { Fragment, useEffect, useMemo, useState } from 'react' import CardContent from '@/components/Cards/CardContent' import VotesBadge from '@/components/VotesBadge' import PollCardResult from '@/components/Cards/PollCardResult' @@ -22,6 +22,7 @@ const PollResult: React.FC = () => { const { roundId, type } = params const { pastPolls, getWebResultByRound, pollResult, setPollResult } = useVoteManagementContext() const { roundEndDate, txUrl, roundState } = useVoteManagementContext() + const [error, setError] = useState(null) const activeTotalCount = type === 'confirmation' ? roundState?.vote_count : pollResult?.totalVotes @@ -37,21 +38,29 @@ const PollResult: React.FC = () => { useEffect(() => { if (pollResult || confirmationPoll || !roundId) return + let cancelled = false const fetchPoll = async () => { - const fetched = await getWebResultByRound(roundId) - if (fetched) { - setPollResult(convertPollData([fetched])[0]) + setError(null) + try { + const fetched = await getWebResultByRound(roundId) + if (!cancelled && fetched) setPollResult(convertPollData([fetched])[0]) + } catch { + if (!cancelled) setError('Could not load the result. Refresh the page to retry.') } } - fetchPoll() + void fetchPoll() + return () => { + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pastPolls, roundId, confirmationPoll, pollResult]) return (
- {loading && ( + {error &&

{error}

} + {loading && !error && (
diff --git a/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx index 85ca70e2e3..200c6a0b0b 100644 --- a/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx +++ b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx @@ -16,6 +16,7 @@ const RoundPoll: React.FC = () => { const navigate = useNavigate() const { roundState, getRoundStateLite, isLoading, currentRoundId } = useVoteManagementContext() const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const isValidRoundId = roundId !== undefined && /^\d+$/.test(roundId) @@ -28,20 +29,32 @@ const RoundPoll: React.FC = () => { // Load the specific round useEffect(() => { + let cancelled = false const loadRound = async () => { if (isValidRoundId && roundId !== undefined) { setLoading(true) - await getRoundStateLite(roundId) - setLoading(false) + setError(null) + try { + await getRoundStateLite(roundId) + } catch { + if (!cancelled) setError('Could not load this round. Refresh the page to retry.') + } finally { + if (!cancelled) setLoading(false) + } } } - loadRound() + void loadRound() + return () => { + cancelled = true + } }, [isValidRoundId, roundId, getRoundStateLite]) const endTime = useMemo(() => (roundState ? convertTimestampToDate(roundState.end_time) : null), [roundState]) const title = `Round #${roundId}` + if (error) return

{error}

+ if (loading || isLoading) { return (
diff --git a/examples/CRISP/client/src/utils/estimated-chain-clock.ts b/examples/CRISP/client/src/utils/estimated-chain-clock.ts new file mode 100644 index 0000000000..ab26ebba05 --- /dev/null +++ b/examples/CRISP/client/src/utils/estimated-chain-clock.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +export interface BlockClockClient { + getBlock: () => Promise<{ timestamp: bigint }> +} + +type Listener = (estimatedTimeMs: number) => void + +// This clock is for display only. Contracts still enforce the input deadline. +class EstimatedChainClock { + private listeners = new Set() + private observedMs = Date.now() + private observedAt = performance.now() + private tick?: ReturnType + private refresh?: ReturnType + private inFlight = false + + constructor(private client?: BlockClockClient) {} + + private now = () => this.observedMs + (performance.now() - this.observedAt) + private emit = () => this.listeners.forEach((listener) => listener(this.now())) + + private synchronize = async () => { + if (!this.client || this.inFlight || !this.listeners.size) return + this.inFlight = true + try { + const block = await this.client.getBlock() + if (this.listeners.size) { + this.observedMs = Number(block.timestamp) * 1000 + this.observedAt = performance.now() + this.emit() + } + } catch { + // Keep the last estimate when the RPC is unavailable. + } finally { + this.inFlight = false + if (this.listeners.size) this.refresh = setTimeout(this.synchronize, 15_000) + } + } + + subscribe(listener: Listener) { + const first = this.listeners.size === 0 + this.listeners.add(listener) + listener(this.now()) + if (first) { + this.tick = setInterval(this.emit, 1_000) + void this.synchronize() + } + return () => { + this.listeners.delete(listener) + if (!this.listeners.size) { + clearInterval(this.tick) + clearTimeout(this.refresh) + } + } + } +} + +const clocks = new WeakMap() +const localClock = new EstimatedChainClock() + +export function subscribeEstimatedChainTime(client: BlockClockClient | undefined, listener: Listener) { + if (!client) return localClock.subscribe(listener) + let clock = clocks.get(client) + if (!clock) { + clock = new EstimatedChainClock(client) + clocks.set(client, clock) + } + return clock.subscribe(listener) +} diff --git a/examples/CRISP/client/tests/estimated-chain-clock.test.ts b/examples/CRISP/client/tests/estimated-chain-clock.test.ts new file mode 100644 index 0000000000..0f4097b578 --- /dev/null +++ b/examples/CRISP/client/tests/estimated-chain-clock.test.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { subscribeEstimatedChainTime } from '../src/utils/estimated-chain-clock' + +beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'performance', 'Date'] })) +afterEach(() => vi.useRealTimers()) + +it('shares block reads while display ticks advance locally', async () => { + const client = { getBlock: vi.fn().mockResolvedValue({ timestamp: 1_000n }) } + const one = vi.fn() + const two = vi.fn() + const stopOne = subscribeEstimatedChainTime(client, one) + const stopTwo = subscribeEstimatedChainTime(client, two) + await vi.advanceTimersByTimeAsync(5_000) + expect(client.getBlock).toHaveBeenCalledTimes(1) + expect(one).toHaveBeenLastCalledWith(1_005_000) + expect(two).toHaveBeenLastCalledWith(1_005_000) + await vi.advanceTimersByTimeAsync(25_000) + expect(client.getBlock).toHaveBeenCalledTimes(3) + stopOne() + stopTwo() + await vi.advanceTimersByTimeAsync(60_000) + expect(client.getBlock).toHaveBeenCalledTimes(3) +}) + +it('does not overlap slow reads, including unsubscribe and resubscribe', async () => { + let resolve!: (block: { timestamp: bigint }) => void + const client = { + getBlock: vi.fn( + () => + new Promise<{ timestamp: bigint }>((done) => { + resolve = done + }), + ), + } + const stop = subscribeEstimatedChainTime(client, vi.fn()) + await vi.advanceTimersByTimeAsync(30_000) + stop() + const stopAgain = subscribeEstimatedChainTime(client, vi.fn()) + expect(client.getBlock).toHaveBeenCalledTimes(1) + resolve({ timestamp: 1n }) + await vi.advanceTimersByTimeAsync(15_000) + expect(client.getBlock).toHaveBeenCalledTimes(2) + stopAgain() + resolve({ timestamp: 2n }) + await vi.advanceTimersByTimeAsync(30_000) + expect(client.getBlock).toHaveBeenCalledTimes(2) +}) + +it('isolates clients and keeps ticking after RPC failure', async () => { + const failedClient = { getBlock: vi.fn().mockRejectedValue(new Error('Offline')) } + const otherClient = { getBlock: vi.fn().mockResolvedValue({ timestamp: 50n }) } + const failedListener = vi.fn() + const otherListener = vi.fn() + const start = Date.now() + const stopFailed = subscribeEstimatedChainTime(failedClient, failedListener) + const stopOther = subscribeEstimatedChainTime(otherClient, otherListener) + await vi.advanceTimersByTimeAsync(5_000) + expect(failedListener).toHaveBeenLastCalledWith(start + 5_000) + expect(otherListener).toHaveBeenLastCalledWith(55_000) + stopFailed() + stopOther() +}) diff --git a/examples/CRISP/client/tests/useArchivePolls.test.ts b/examples/CRISP/client/tests/useArchivePolls.test.ts new file mode 100644 index 0000000000..ef39a90747 --- /dev/null +++ b/examples/CRISP/client/tests/useArchivePolls.test.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { afterEach, expect, it, vi } from 'vitest' +import { createElement, useLayoutEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { useArchivePolls } from '../src/hooks/voting/useArchivePolls' +import type { ArchivePage, PollRequestResult } from '../src/model/poll.model' + +let renderer: ReactTestRenderer | undefined +let archive: ReturnType +function Probe({ fetchPage }: { fetchPage: (cursor?: string) => Promise }) { + const value = useArchivePolls(fetchPage) + useLayoutEffect(() => { + archive = value + }) + return null +} +const row = (round_id: string): PollRequestResult => ({ + round_id, + tally: [1, 0], + option_1_emoji: 'one', + option_2_emoji: 'two', + end_time: 1, + total_votes: 1, +}) +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +it('shows loaded rows immediately and requests the next cursor only once', async () => { + let resolve!: (page: ArchivePage) => void + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: [row('1')], next_cursor: 'v1:2' }) + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }), + ) + await act(async () => { + renderer = create(createElement(Probe, { fetchPage })) + }) + expect(archive.items.map((item) => item.round_id)).toEqual(['1']) + let pending!: Promise + act(() => { + pending = archive.loadMore() + void archive.loadMore() + }) + expect(fetchPage).toHaveBeenCalledTimes(2) + expect(fetchPage).toHaveBeenLastCalledWith('v1:2') + expect(archive.items).toHaveLength(1) + await act(async () => { + resolve({ items: [row('1'), row('340282366920938463463374607431768211456')], next_cursor: null }) + await pending + }) + expect(archive.items).toHaveLength(2) + expect(archive.hasMore).toBe(false) +}) + +it('retains the cursor after a failure and allows a retry', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: [], next_cursor: 'v1:5' }) + .mockRejectedValueOnce(new Error('Offline')) + .mockResolvedValueOnce({ items: [row('5')], next_cursor: null }) + await act(async () => { + renderer = create(createElement(Probe, { fetchPage })) + }) + await act(async () => { + await archive.loadMore() + }) + expect(archive.error).toContain('Try again') + expect(archive.hasMore).toBe(true) + await act(async () => { + await archive.loadMore() + }) + expect(fetchPage.mock.calls.slice(1)).toEqual([['v1:5'], ['v1:5']]) + expect(archive.items[0].round_id).toBe('5') + expect(archive.error).toBeNull() +}) + +it('discards late results after changing the data source or unmounting', async () => { + let resolve!: (page: ArchivePage) => void + const oldFetch = vi.fn( + () => + new Promise((done) => { + resolve = done + }), + ) + const newFetch = vi.fn().mockResolvedValue({ items: [row('new')], next_cursor: null }) + act(() => { + renderer = create(createElement(Probe, { fetchPage: oldFetch })) + }) + await act(async () => { + renderer!.update(createElement(Probe, { fetchPage: newFetch })) + }) + await act(async () => { + resolve({ items: [row('old')], next_cursor: null }) + await Promise.resolve() + }) + expect(archive.items.map((item) => item.round_id)).toEqual(['new']) +}) diff --git a/examples/CRISP/client/tests/useFetchApi.test.ts b/examples/CRISP/client/tests/useFetchApi.test.ts new file mode 100644 index 0000000000..0128b7dbc1 --- /dev/null +++ b/examples/CRISP/client/tests/useFetchApi.test.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createElement, useLayoutEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import axios from 'axios' +import { useApi } from '../src/hooks/generic/useFetchApi' + +vi.mock('axios', () => ({ + default: { request: vi.fn(), isAxiosError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true }, +})) +vi.mock('@/utils/handle-generic-error', () => ({ handleGenericError: vi.fn() })) +let api: ReturnType +let renderer: ReactTestRenderer +function Probe() { + const value = useApi() + useLayoutEffect(() => { + api = value + }) + return null +} +beforeEach(() => { + vi.mocked(axios.request).mockReset() + act(() => { + renderer = create(createElement(Probe)) + }) +}) +afterEach(() => act(() => renderer.unmount())) + +it.each(['get', 'GET', 'post', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const)('dispatches %s without changing it to POST', async (method) => { + vi.mocked(axios.request).mockResolvedValue({ data: { ok: true } }) + let response: unknown + await act(async () => { + response = await api.fetchData('/round', method, { id: 1 }, { timeout: 500, params: { page: 2 } }) + }) + expect(response).toEqual({ ok: true }) + expect(axios.request).toHaveBeenCalledWith({ url: '/round', method, data: { id: 1 }, timeout: 500, params: { page: 2 } }) + expect(api.isLoading).toBe(false) +}) + +it('rejects original errors and suppresses only an explicitly allowed 404', async () => { + const unavailable = { isAxiosError: true, response: { status: 503 } } + vi.mocked(axios.request).mockRejectedValue(unavailable) + await act(async () => { + await expect(api.fetchData('/round', 'get', undefined, { suppressNotFound: true })).rejects.toBe(unavailable) + }) + const missing = { isAxiosError: true, response: { status: 404 } } + vi.mocked(axios.request).mockRejectedValue(missing) + await act(async () => { + await expect(api.fetchData('/round')).rejects.toBe(missing) + }) + await act(async () => { + await expect(api.fetchData('/round', 'get', undefined, { suppressNotFound: true })).resolves.toBeUndefined() + }) + expect(api.isLoading).toBe(false) +}) + +it('stays loading until every concurrent request settles', async () => { + let resolveFirst!: (value: unknown) => void + let resolveSecond!: (value: unknown) => void + vi.mocked(axios.request) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve + }), + ) + let first!: Promise + let second!: Promise + const fetchData = api.fetchData + act(() => { + first = api.fetchData('/one') + second = api.fetchData('/two') + }) + expect(api.isLoading).toBe(true) + expect(api.fetchData).toBe(fetchData) + await act(async () => { + resolveSecond({ data: 2 }) + await second + }) + expect(api.isLoading).toBe(true) + await act(async () => { + resolveFirst({ data: 1 }) + await first + }) + expect(api.isLoading).toBe(false) +}) diff --git a/examples/CRISP/client/vitest.config.ts b/examples/CRISP/client/vitest.config.ts new file mode 100644 index 0000000000..a6eebb5b1a --- /dev/null +++ b/examples/CRISP/client/vitest.config.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, + test: { include: ['tests/**/*.test.{ts,tsx}'] }, +}) diff --git a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts index 81d5ac766a..59463003c7 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/utils.test.ts @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { expect, describe, it } from 'vitest' +import { bytesToHex } from 'viem' import { extractSignatureComponents, generateMerkleProof, generateMerkleTree, hashLeaf } from '../src/utils' import { SLOT_ADDRESS } from './constants' import { generateTestLeaves } from './helpers' @@ -21,11 +22,9 @@ describe('Utils', () => { }) describe('generateMerkleTree', () => { - it('Should generate a merkle tree', () => { - const leaves = generateTestLeaves([{ address: SLOT_ADDRESS, balance: 100n }]) - const tree = generateMerkleTree(leaves) - - expect(tree.root).toBeDefined() + it('matches the known root for an odd number of leaves', () => { + const tree = generateMerkleTree([1n, 2n, 3n]) + expect(tree.root).toBe(13816780880028945690020260331303642730075999758909899334839547418969502592169n) }) }) @@ -47,6 +46,7 @@ describe('Utils', () => { } expect(tree.verifyProof(unpaddedProof)).toBe(true) + expect(tree.verifyProof({ ...unpaddedProof, leaf: hashLeaf(address, balance + 1n) })).toBe(false) }) it('Should return path indices in least-significant-bit-first order', () => { @@ -81,10 +81,10 @@ describe('Utils', () => { it('Should extract signature components correctly', async () => { const { messageHash, publicKeyX, publicKeyY, signature: extractedSignature } = await extractSignatureComponents(MASK_SIGNATURE) - expect(messageHash).toBeInstanceOf(Uint8Array) - expect(publicKeyX).toBeInstanceOf(Uint8Array) - expect(publicKeyY).toBeInstanceOf(Uint8Array) - expect(extractedSignature).toBeInstanceOf(Uint8Array) + expect(bytesToHex(messageHash)).toBe('0x136f9726bf0927af0b8be9fd5b24fe25ee8047f7940e9efc359d7caf154110fd') + expect(bytesToHex(publicKeyX)).toBe('0x803f440eb94e8a18831bb33268d20363b8c6e632fe425de5a9b16e6caa2d6bf6') + expect(bytesToHex(publicKeyY)).toBe('0x7d8572b3029dbc17a0021271fee5faf58f1367104b96df09d923892984acf77e') + expect(bytesToHex(extractedSignature)).toBe(MASK_SIGNATURE.slice(0, 130)) }) }) }) diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index b97228457b..723c8f374d 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -1575,6 +1575,9 @@ pub async fn start_indexer( } } + CurrentRoundRepository::new(crisp_indexer.get_store()) + .ensure_requester_index() + .await?; restore_round_deadline_callbacks(&crisp_indexer).await?; crisp_indexer.listen().await?; info!("CRISP: Indexer listen loop has finished!"); diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 69ff185702..faf6472f7f 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -196,6 +196,50 @@ pub struct WebResultRequest { pub requester: String, } +#[derive(Debug, Deserialize)] +pub struct ArchiveRequest { + #[serde(default)] + pub requesters: Vec, + #[serde(default)] + pub cursor: Option, + #[serde(default = "archive_page_size")] + pub limit: usize, +} + +fn archive_page_size() -> usize { + 12 +} + +impl ArchiveRequest { + pub fn before(&self) -> eyre::Result> { + eyre::ensure!( + (1..=50).contains(&self.limit), + "Archive limit must be between 1 and 50" + ); + self.cursor + .as_ref() + .map(|cursor| { + let position = cursor + .strip_prefix("v1:") + .ok_or_else(|| eyre::eyre!("Invalid archive cursor"))?; + eyre::ensure!( + !position.is_empty() && position.bytes().all(|byte| byte.is_ascii_digit()), + "Invalid archive cursor" + ); + position + .parse::() + .map_err(|_| eyre::eyre!("Invalid archive cursor")) + }) + .transpose() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ArchivePage { + pub items: Vec, + pub next_cursor: Option, +} + #[derive(Debug, Deserialize, Serialize)] pub struct E3StateLite { pub id: String, diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 63e50ca09e..3c27a5b5cf 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -15,10 +15,49 @@ use eyre::Result; use fhe::bfv::BfvParameters; use log::info; use num_bigint::BigUint; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] struct RoundIndex { ids: Vec, + #[serde(default)] + schema_version: u8, + #[serde(default)] + requesters: BTreeMap>, +} + +impl RoundIndex { + fn page( + &self, + requesters: &[String], + before: Option, + limit: usize, + ) -> (Vec, Option) { + let end = before.unwrap_or(self.ids.len()).min(self.ids.len()); + let selected: Vec = if requesters.is_empty() { + (0..end).rev().take(limit + 1).collect() + } else { + let positions: BTreeSet = requesters + .iter() + .filter_map(|requester| self.requesters.get(&requester.to_lowercase())) + .flat_map(|positions| positions.range(..end).rev().take(limit + 1).copied()) + .collect(); + positions.into_iter().rev().take(limit + 1).collect() + }; + let next = if selected.len() > limit { + selected + .get(limit - 1) + .map(|position| format!("v1:{position}")) + } else { + None + }; + let ids = selected + .into_iter() + .take(limit) + .map(|position| self.ids[position].clone()) + .collect(); + (ids, next) + } } pub struct CurrentRoundRepository { @@ -41,13 +80,31 @@ impl CurrentRoundRepository { pub async fn record_round(&mut self, e3_id: impl ToString) -> Result<()> { let e3_id = e3_id.to_string(); + let requester = CrispE3Repository::new(self.store.clone(), &e3_id) + .get_crisp() + .await? + .requester + .to_lowercase(); let key = self.round_index_key(); + self.read_round_index().await?; self.store .modify(&key, |index: Option| { - let mut index = index.unwrap_or_default(); - if !index.ids.contains(&e3_id) { + let mut index = index.unwrap_or_else(|| RoundIndex { + schema_version: 1, + ..Default::default() + }); + let position = if let Some(position) = index.ids.iter().position(|id| id == &e3_id) + { + position + } else { index.ids.push(e3_id.clone()); - } + index.ids.len() - 1 + }; + index + .requesters + .entry(requester.clone()) + .or_default() + .insert(position); Some(index) }) .await @@ -55,6 +112,80 @@ impl CurrentRoundRepository { Ok(()) } + async fn read_round_index(&self) -> Result { + let index = self + .store + .get::(&self.round_index_key()) + .await + .map_err(|error| eyre::eyre!("Could not read the round index: {error}"))? + .unwrap_or_else(|| RoundIndex { + schema_version: 1, + ..Default::default() + }); + eyre::ensure!( + index.schema_version <= 1, + "Unsupported round index schema {}. Use a compatible server.", + index.schema_version + ); + Ok(index) + } + + /// Add requester positions to legacy JSON records without changing round order. + pub async fn ensure_requester_index(&self) -> Result<()> { + let index = self.read_round_index().await?; + if index.schema_version == 1 { + return Ok(()); + } + let mut requesters: BTreeMap> = BTreeMap::new(); + for (position, id) in index.ids.iter().enumerate() { + if let Some(round) = CrispE3Repository::new(self.store.clone(), id) + .try_get_crisp() + .await? + { + requesters + .entry(round.requester.to_lowercase()) + .or_default() + .insert(position); + } + } + // Merge under the store lock so concurrent appends are retained. + self.store + .clone() + .modify(&self.round_index_key(), |current: Option| { + current.map(|mut current| { + for (requester, positions) in &requesters { + current + .requesters + .entry(requester.clone()) + .or_default() + .extend(positions); + } + current.schema_version = 1; + current + }) + }) + .await + .map_err(|error| eyre::eyre!("Could not migrate the requester index: {error}"))?; + Ok(()) + } + + pub async fn get_archive_round_ids( + &self, + requesters: &[String], + before: Option, + limit: usize, + ) -> Result<(Vec, Option)> { + eyre::ensure!( + (1..=50).contains(&limit), + "Archive limit must be between 1 and 50" + ); + self.ensure_requester_index().await?; + Ok(self + .read_round_index() + .await? + .page(requesters, before, limit)) + } + pub async fn get_round_ids(&self) -> Result> { let key = self.round_index_key(); let index = self @@ -88,15 +219,8 @@ impl CurrentRoundRepository { &self, requester: String, ) -> Result> { - for round_id in self.get_round_ids().await?.into_iter().rev() { - let crisp_repo = CrispE3Repository::new(self.store.clone(), &round_id); - - if crisp_repo.is_requested_by(&requester).await? { - return Ok(Some(CurrentRound { id: round_id })); - } - } - - Ok(None) + let (ids, _) = self.get_archive_round_ids(&[requester], None, 1).await?; + Ok(ids.into_iter().next().map(|id| CurrentRound { id })) } fn current_round_key(&self) -> String { @@ -171,14 +295,6 @@ impl CrispE3Repository { Ok(self.try_get_crisp().await?.is_some()) } - /// Whether the request-time CRISP record belongs to `requester`. - pub async fn is_requested_by(&self, requester: &str) -> Result { - Ok(self - .try_get_crisp() - .await? - .is_some_and(|round| round.requester.eq_ignore_ascii_case(requester))) - } - /// Whether the generic indexer stored a verified committee public key for this round. pub async fn has_indexed_public_key(&self) -> Result { Ok(self.try_get_e3().await?.is_some()) @@ -521,11 +637,11 @@ impl CrispE3Repository { }; Ok(Some(WebResultRequest { round_id: e3.id, + total_votes: count_active_slots(&e3_crisp.input_slots), tally: e3_crisp.tally, option_1_emoji: e3_crisp.emojis[0].clone(), option_2_emoji: e3_crisp.emojis[1].clone(), end_time: e3.input_window[1], - total_votes: self.get_vote_count().await?, requester: e3_crisp.requester, })) } @@ -798,10 +914,10 @@ pub fn parse_slot_address(address: &str) -> Result<[u8; 20]> { mod tests { use super::{ count_active_slots, parse_slot_address, snapshot_block, CrispE3Repository, - CurrentRoundRepository, + CurrentRoundRepository, RoundIndex, }; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp}; - use e3_sdk::indexer::{InMemoryStore, SharedStore}; + use e3_sdk::indexer::{DataStore, InMemoryStore, SharedStore}; use std::sync::Arc; use tokio::sync::RwLock; @@ -835,6 +951,132 @@ mod tests { } } + #[tokio::test] + async fn archive_migrates_legacy_index_and_retains_cursor_order_after_replay_and_restart() { + let mut store = test_store(); + let legacy: RoundIndex = + serde_json::from_str(include_str!("../../tests/fixtures/round-index-v0.json")).unwrap(); + for (position, id) in legacy.ids.iter().enumerate() { + CrispE3Repository::new(store.clone(), id) + .set_crisp(crisp_round( + if position == 1 { "other" } else { "requester" }, + "Requested", + )) + .await + .unwrap(); + } + store.insert("_e3:round_index", &legacy).await.unwrap(); + let mut current = CurrentRoundRepository::new(store.clone()); + current.ensure_requester_index().await.unwrap(); + let migrated = current.read_round_index().await.unwrap(); + assert_eq!(migrated.schema_version, 1); + assert_eq!(migrated.ids, legacy.ids); + let (first, cursor) = current + .get_archive_round_ids(&["REQUESTER".into()], None, 1) + .await + .unwrap(); + assert_eq!(first, ["3"]); + assert_eq!(cursor.as_deref(), Some("v1:2")); + + CrispE3Repository::new(store.clone(), "4") + .set_crisp(crisp_round("requester", "Requested")) + .await + .unwrap(); + current.record_round("4").await.unwrap(); + current.record_round("3").await.unwrap(); + let restarted = CurrentRoundRepository::new(store.clone()); + let (second, next) = restarted + .get_archive_round_ids(&["requester".into()], Some(2), 1) + .await + .unwrap(); + assert_eq!(second, ["1"]); + assert_eq!(next, None); + let (all, _) = restarted + .get_archive_round_ids(&[], None, 50) + .await + .unwrap(); + assert_eq!( + all, + ["4", "3", "340282366920938463463374607431768211456", "1"] + ); + + // A page reads the index, not the historical CRISP records. + for id in &all { + store + .insert(&format!("_e3:crisp:{id}"), &"invalid round record") + .await + .unwrap(); + } + assert_eq!( + restarted + .get_archive_round_ids(&["requester".into()], None, 2) + .await + .unwrap() + .0, + ["4", "3"] + ); + } + + #[tokio::test] + async fn archive_migration_is_idempotent_and_does_not_stamp_a_failed_backfill() { + let mut store = test_store(); + let legacy: RoundIndex = + serde_json::from_str(include_str!("../../tests/fixtures/round-index-v0.json")).unwrap(); + store.insert("_e3:round_index", &legacy).await.unwrap(); + store + .insert("_e3:crisp:1", &"invalid round record") + .await + .unwrap(); + let current = CurrentRoundRepository::new(store.clone()); + assert!(current.ensure_requester_index().await.is_err()); + assert_eq!(current.read_round_index().await.unwrap().schema_version, 0); + CrispE3Repository::new(store.clone(), "1") + .set_crisp(crisp_round("requester", "Requested")) + .await + .unwrap(); + current.ensure_requester_index().await.unwrap(); + current.ensure_requester_index().await.unwrap(); + assert_eq!( + current + .get_archive_round_ids(&["requester".into(), "REQUESTER".into()], None, 12) + .await + .unwrap() + .0, + ["1"] + ); + + let unsupported = RoundIndex { + schema_version: 2, + ..Default::default() + }; + store.insert("_e3:round_index", &unsupported).await.unwrap(); + assert!(current + .ensure_requester_index() + .await + .unwrap_err() + .to_string() + .contains("Unsupported round index schema")); + } + + #[test] + fn archive_request_rejects_invalid_limits_and_cursors() { + use crate::server::models::ArchiveRequest; + let valid: ArchiveRequest = + serde_json::from_str(r#"{"cursor":"v1:123","requesters":[]}"#).unwrap(); + assert_eq!(valid.before().unwrap(), Some(123)); + assert_eq!(valid.limit, 12); + for input in [ + r#"{"limit":0}"#, + r#"{"limit":51}"#, + r#"{"cursor":"v2:3"}"#, + r#"{"cursor":"v1:-1"}"#, + r#"{"cursor":"v1:184467440737095516160"}"#, + ] { + let request: ArchiveRequest = serde_json::from_str(input).unwrap(); + assert!(request.before().is_err(), "{input}"); + } + } + #[test] fn counts_each_slot_once_no_matter_how_long_its_chain_is() { let slot_a = [1u8; 20]; diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs index 366f9df6be..ddfca940a1 100644 --- a/examples/CRISP/server/src/server/routes/state.rs +++ b/examples/CRISP/server/src/server/routes/state.rs @@ -10,8 +10,9 @@ use crate::server::{ app_data::AppData, data_availability::AvailabilityService, models::{ - canonical_e3_id, e3_id_to_u256, GetRoundRequest, JsonResponse, PreviousCiphertextRequest, - PreviousCiphertextResponse, RoundRequestWithRequester, WebhookPayload, + canonical_e3_id, e3_id_to_u256, ArchivePage, ArchiveRequest, GetRoundRequest, JsonResponse, + PreviousCiphertextRequest, PreviousCiphertextResponse, RoundRequestWithRequester, + WebhookPayload, }, rate_limit::ChainRateLimiter, }; @@ -29,6 +30,7 @@ pub fn setup_routes(config: &mut web::ServiceConfig) { web::scope("/state") .route("/result", web::post().to(get_round_result)) .route("/all", web::post().to(get_all_round_results)) + .route("/archive", web::post().to(get_archive_page)) .route("/lite", web::post().to(get_round_state_lite)) // The handler verifies the compute proof on Ethereum before it creates an Avail job. // Valid retries are idempotent, so this endpoint needs no separate caller identity. @@ -288,6 +290,40 @@ async fn get_all_round_results( HttpResponse::Ok().json(states) } +async fn get_archive_page( + data: web::Json, + store: web::Data, +) -> impl Responder { + let before = match data.before() { + Ok(before) => before, + Err(error) => return HttpResponse::BadRequest().body(error.to_string()), + }; + let (ids, next_cursor) = match store + .current_round() + .get_archive_round_ids(&data.requesters, before, data.limit) + .await + { + Ok(page) => page, + Err(error) => { + error!("Could not read the archive index: {error}"); + return HttpResponse::InternalServerError().body("Could not read the archive index"); + } + }; + let mut items = Vec::with_capacity(ids.len()); + for id in ids { + match store.e3(id).try_get_web_result_request().await { + Ok(Some(summary)) => items.push(summary), + Ok(None) => {} + Err(error) => { + error!("Could not read an archive summary: {error}"); + return HttpResponse::InternalServerError() + .body("Could not read an archive summary"); + } + } + } + HttpResponse::Ok().json(ArchivePage { items, next_cursor }) +} + /// Get the state for a given round /// /// # Arguments @@ -368,3 +404,154 @@ async fn handle_get_eligible_addresses( } } } + +#[cfg(test)] +mod archive_tests { + use super::setup_routes; + use crate::server::{app_data::AppData, database::SledDB}; + use actix_web::{http::StatusCode, test, web, App}; + use e3_sdk::{ + evm_helpers::contracts::CommitteeSize, + indexer::{models::E3, DataStore, SharedStore}, + }; + use serde_json::{json, Value}; + use std::sync::Arc; + use tokio::sync::RwLock; + + const FULL_WIDTH_ID: &str = "340282366920938463463374607431768211456"; + + async fn fixture() -> (web::Data, SharedStore) { + let db = SledDB { + db: sled::Config::new().temporary(true).open().unwrap(), + }; + let mut store = SharedStore::new(Arc::new(RwLock::new(db))); + store + .insert( + "_e3:round_index", + &json!({ + "ids": [FULL_WIDTH_ID, "1", "2"], "schema_version": 1, + "requesters": {"requester": [0, 2], "other": [1]} + }), + ) + .await + .unwrap(); + let crisp = json!({ + "emojis": ["one", "two"], "start_time": 0, "end_time": 100, + "status": "Finished", "tally": ["7", "3"], "token_holder_hashes": [], + "eligible_addresses": [], "token_address": "token", "balance_threshold": "1", + "ciphertext_inputs": [], "requester": "requester", "num_options": "2", + "credit_mode": 0, "credits": "1" + }); + for id in [FULL_WIDTH_ID, "2"] { + store + .insert(&format!("_e3:crisp:{id}"), &crisp) + .await + .unwrap(); + } + store + .insert("_e3:1", &"unselected invalid record") + .await + .unwrap(); + let e3 = E3 { + chain_id: 1, + id: FULL_WIDTH_ID.into(), + input_window: [0, 100], + ciphertext_inputs: vec![], + ciphertext_output: vec![], + ciphertext_output_reference: None, + ciphertext_commitment: vec![], + committee_public_key: vec![1], + committee_public_key_hash: vec![], + e3_params: vec![], + custom_params: vec![], + interfold_address: "contract".into(), + encryption_scheme_id: vec![], + crypto_config_id: vec![], + plaintext_output: vec![], + request_block: 1, + seed: [0; 32], + committee_size: CommitteeSize::Minimum, + requester: "requester".into(), + }; + store + .insert(&format!("_e3:{FULL_WIDTH_ID}"), &e3) + .await + .unwrap(); + (web::Data::new(AppData::new(store.clone())), store) + } + + #[actix_web::test] + async fn archive_http_pages_pending_rounds_and_returns_full_width_summary_ids() { + let (data, _) = fixture().await; + let app = test::init_service(App::new().app_data(data).configure(setup_routes)).await; + let first: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/state/archive") + .set_json(json!({"requesters": ["REQUESTER"], "limit": 1})) + .to_request(), + ) + .await; + assert_eq!(first, json!({"items": [], "next_cursor": "v1:2"})); + let second: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/state/archive") + .set_json(json!({ + "requesters": ["requester"], "limit": 1, "cursor": first["next_cursor"] + })) + .to_request(), + ) + .await; + assert_eq!( + second, + json!({"items": [{ + "round_id": FULL_WIDTH_ID, "tally": ["7", "3"], "option_1_emoji": "one", + "option_2_emoji": "two", "total_votes": 0, "end_time": 100, "requester": "requester" + }], "next_cursor": null}) + ); + } + + #[actix_web::test] + async fn archive_http_reports_bad_requests_and_store_failures() { + let (data, mut store) = fixture().await; + let app = test::init_service(App::new().app_data(data).configure(setup_routes)).await; + for body in [ + json!({"limit": 0}), + json!({"limit": 51}), + json!({"cursor": "v2:1"}), + ] { + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/state/archive") + .set_json(body) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/state/archive") + .set_json(json!({"requesters": ["other"]})) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + store + .insert("_e3:round_index", &"invalid index") + .await + .unwrap(); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/state/archive") + .set_json(json!({})) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/examples/CRISP/server/tests/fixtures/round-index-v0.json b/examples/CRISP/server/tests/fixtures/round-index-v0.json new file mode 100644 index 0000000000..6a0e81b9fb --- /dev/null +++ b/examples/CRISP/server/tests/fixtures/round-index-v0.json @@ -0,0 +1 @@ +{"ids":["1","340282366920938463463374607431768211456","3"]} diff --git a/package.json b/package.json index 4540ed284f..5d702b0a1e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "provenance:manifest": "tsx scripts/generate-provenance-manifest.ts", "check:verifiers": "tsx scripts/generate-verifiers.ts --circuits dkg_aggregator,decryption_aggregator --check", "test:circuit-tooling": "tsx --test scripts/circuit-artifacts.test.ts", - "test": "pnpm evm:test && pnpm rust:test && pnpm sdk:test && pnpm noir:test", + "test": "pnpm evm:test && pnpm rust:test && pnpm rust:test:slashing && pnpm rust:test:proofs && pnpm sdk:test && pnpm sdk:test:proofs:prepared && pnpm test:web && pnpm noir:test", + "test:web": "pnpm react:test && pnpm dashboard:test && pnpm crisp:test:client", "test:integration": "cd ./tests/integration && ./test.sh", "coverage": "pnpm evm:coverage", "prepare": "husky", @@ -43,6 +44,8 @@ "ciphernode:add": "cd packages/interfold-contracts && pnpm ciphernode:admin-add", "ciphernode:remove": "cd packages/interfold-contracts && pnpm ciphernode:remove", "rust:test": "cd crates && ./scripts/test.sh", + "rust:test:proofs": "cargo test --locked -p e3-zk-prover --test fold_accumulators_e2e_tests --test node_fold_correlated_e2e_tests -- --include-ignored --nocapture --test-threads=1", + "rust:test:slashing": "cargo test --locked -p e3-zk-prover --test slashing_integration_tests -- --include-ignored --nocapture --test-threads=1", "noir:test": "./scripts/test-circuits.sh", "noir:lint": "./scripts/lint-circuits.sh", "rust:build": "cargo build --locked --release", @@ -68,8 +71,13 @@ "mcp:build": "cd packages/interfold-mcp && pnpm build", "mcp:release": "cd packages/interfold-mcp && pnpm release", "react:build": "cd packages/interfold-react && pnpm build", + "react:test": "pnpm -C packages/interfold-react test", + "dashboard:test": "pnpm -C packages/interfold-dashboard test", + "crisp:test:client": "pnpm -C examples/CRISP/client test", "sdk:build": "cd packages/interfold-sdk && pnpm build", "sdk:test": "cd packages/interfold-sdk && pnpm test", + "sdk:test:proofs": "cd packages/interfold-sdk && pnpm test:proofs", + "sdk:test:proofs:prepared": "pnpm -C packages/interfold-sdk test:proofs:prepared", "sdk:release": "cd packages/interfold-sdk && pnpm release", "wasm:release": "cd crates/wasm && pnpm release", "config:release": "cd packages/interfold-config && pnpm release", diff --git a/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol b/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol new file mode 100644 index 0000000000..926e74c8b8 --- /dev/null +++ b/packages/interfold-contracts/contracts/test/MockSlashingBondingRegistry.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.28; + +/// @notice Records slashing calls without holding or transferring collateral. +contract MockSlashingBondingRegistry { + uint64 public constant bondingAssetConfigurationVersion = 1; + uint256 public ticketPenaltyRequested; + uint256 public bondPenaltyRequested; + uint256 public openLocks; + + function snapshotSlashRouteDestination( + uint256, + address, + address + ) external {} + + function openSlashLock(uint256, uint256, address) external { + openLocks++; + } + + function closeSlashLock(uint256, address) external { + openLocks--; + } + + function slashTicketBalance( + address, + uint256 amount, + bytes32 + ) external returns (uint256) { + ticketPenaltyRequested += amount; + return 0; + } + + function slashCiphernodeBond( + address, + uint256 amount, + bytes32 + ) external returns (uint256) { + bondPenaltyRequested += amount; + return 0; + } +} diff --git a/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts b/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts index 6613b0f0e2..5cb52b7fc8 100644 --- a/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts +++ b/packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts @@ -8,7 +8,7 @@ // SortitionCommitteeFinalized event rename, and append-only parameter sets. import { expect } from "chai"; -import { BFV_PARAMS_DEFAULT, deployInterfoldSystem, ethers } from "../fixtures"; +import { BFV_PARAMS_DEFAULT, deployInterfoldSystem, ethers, networkHelpers } from "../fixtures"; async function deployAll() { const sys = await deployInterfoldSystem({ @@ -31,7 +31,7 @@ async function deployAll() { describe("Governance — access control, bounds & events", function () { describe("Ownable2Step + renounceOwnership disabled", function () { it("Interfold: transferOwnership is two-step", async function () { - const { interfold, other, ownerAddress } = await deployAll(); + const { interfold, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await interfold.transferOwnership(otherAddress); expect(await interfold.owner()).to.equal(ownerAddress); @@ -41,7 +41,7 @@ describe("Governance — access control, bounds & events", function () { }); it("CiphernodeRegistry: transferOwnership is two-step", async function () { - const { ciphernodeRegistry, other, ownerAddress } = await deployAll(); + const { ciphernodeRegistry, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await ciphernodeRegistry.transferOwnership(otherAddress); expect(await ciphernodeRegistry.owner()).to.equal(ownerAddress); @@ -51,7 +51,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry: transferOwnership is two-step", async function () { - const { bondingRegistry, other, ownerAddress } = await deployAll(); + const { bondingRegistry, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await bondingRegistry.transferOwnership(otherAddress); expect(await bondingRegistry.owner()).to.equal(ownerAddress); @@ -61,7 +61,7 @@ describe("Governance — access control, bounds & events", function () { }); it("E3RefundManager: transferOwnership is two-step", async function () { - const { e3RefundManager, other, ownerAddress } = await deployAll(); + const { e3RefundManager, other, ownerAddress } = await networkHelpers.loadFixture(deployAll); const otherAddress = await other.getAddress(); await e3RefundManager.transferOwnership(otherAddress); expect(await e3RefundManager.owner()).to.equal(ownerAddress); @@ -71,7 +71,7 @@ describe("Governance — access control, bounds & events", function () { }); it("InterfoldToken: renounceOwnership reverts", async function () { - const { ciphernodeBondToken } = await deployAll(); + const { ciphernodeBondToken } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeBondToken.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -81,14 +81,14 @@ describe("Governance — access control, bounds & events", function () { }); it("InterfoldTicketToken: renounceOwnership reverts", async function () { - const { ticketToken } = await deployAll(); + const { ticketToken } = await networkHelpers.loadFixture(deployAll); await expect( ticketToken.renounceOwnership(), ).to.be.revertedWithCustomError(ticketToken, "RenounceOwnershipDisabled"); }); it("Interfold: renounceOwnership reverts", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); await expect(interfold.renounceOwnership()).to.be.revertedWithCustomError( interfold, "RenounceOwnershipDisabled", @@ -96,7 +96,7 @@ describe("Governance — access control, bounds & events", function () { }); it("CiphernodeRegistry: renounceOwnership reverts", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeRegistry.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -106,7 +106,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry: renounceOwnership reverts", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); await expect( bondingRegistry.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -116,7 +116,7 @@ describe("Governance — access control, bounds & events", function () { }); it("E3RefundManager: renounceOwnership reverts", async function () { - const { e3RefundManager } = await deployAll(); + const { e3RefundManager } = await networkHelpers.loadFixture(deployAll); await expect( e3RefundManager.renounceOwnership(), ).to.be.revertedWithCustomError( @@ -128,7 +128,7 @@ describe("Governance — access control, bounds & events", function () { describe("Interfold bounds exposed", function () { it("setMaxDuration reverts above MAX_DURATION_CAP", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); const cap = await interfold.MAX_DURATION_CAP(); await expect( interfold.setMaxDuration(cap + 1n), @@ -136,7 +136,7 @@ describe("Governance — access control, bounds & events", function () { }); it("exposes MAX_TIMEOUT_WINDOW / MAX_COMMITTEE_SIZE / MAX_*_BPS", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); expect(await interfold.MAX_DURATION_CAP()).to.equal( 365n * 24n * 60n * 60n, ); @@ -151,7 +151,7 @@ describe("Governance — access control, bounds & events", function () { describe("registry & bonding bounds", function () { it("setSortitionSubmissionWindow reverts when out of bounds", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); await expect( ciphernodeRegistry.setSortitionSubmissionWindow(0), ).to.be.revertedWithCustomError( @@ -168,7 +168,7 @@ describe("Governance — access control, bounds & events", function () { }); it("BondingRegistry.setExitDelay reverts when out of bounds", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); const min = await bondingRegistry.MIN_EXIT_DELAY(); await expect( bondingRegistry.setExitDelay(min - 1n), @@ -180,7 +180,7 @@ describe("Governance — access control, bounds & events", function () { }); it("keeps exit delay longer than the sortition window", async function () { - const { bondingRegistry, ciphernodeRegistry } = await deployAll(); + const { bondingRegistry, ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); const minimumExitDelay = await bondingRegistry.MIN_EXIT_DELAY(); const randomnessTimeout = await ciphernodeRegistry.randomnessRequestTimeout(); @@ -208,12 +208,12 @@ describe("Governance — access control, bounds & events", function () { describe("bps and appeal-window caps exposed", function () { it("E3RefundManager exposes MAX_PROTOCOL_BPS", async function () { - const { e3RefundManager } = await deployAll(); + const { e3RefundManager } = await networkHelpers.loadFixture(deployAll); expect(await e3RefundManager.MAX_PROTOCOL_BPS()).to.equal(5_000n); }); it("SlashingManager exposes MAX_APPEAL_WINDOW", async function () { - const { slashingManager } = await deployAll(); + const { slashingManager } = await networkHelpers.loadFixture(deployAll); expect(await slashingManager.MAX_APPEAL_WINDOW()).to.equal( 30n * 24n * 60n * 60n, ); @@ -222,7 +222,7 @@ describe("Governance — access control, bounds & events", function () { describe("BondingRegistry distributor cap", function () { it("reverts after MAX_AUTHORIZED_DISTRIBUTORS, succeeds after revoke", async function () { - const { bondingRegistry } = await deployAll(); + const { bondingRegistry } = await networkHelpers.loadFixture(deployAll); const cap = await bondingRegistry.MAX_AUTHORIZED_DISTRIBUTORS(); const distributors: string[] = []; for (let i = 0; i < Number(cap); i++) { @@ -244,7 +244,7 @@ describe("Governance — access control, bounds & events", function () { describe("PkVerifierSet event", function () { it("emits PkVerifierSet when setPkVerifier is called", async function () { - const { interfold, mocks } = await deployAll(); + const { interfold, mocks } = await networkHelpers.loadFixture(deployAll); const schemeId = ethers.id("pk-verifier-event"); const verifier = await mocks.pkVerifier.getAddress(); await expect(interfold.setPkVerifier(schemeId, verifier)) @@ -253,7 +253,7 @@ describe("Governance — access control, bounds & events", function () { }); it("rejects verifiers compiled for another committee", async function () { - const { interfold, ciphernodeRegistry } = await deployAll(); + const { interfold, ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); const circuitVerifier = await ethers.deployContract( "MockCircuitVerifier", ); @@ -295,7 +295,7 @@ describe("Governance — access control, bounds & events", function () { describe("SlashingManager setter events", function () { it("emits BondingRegistryUpdated", async function () { - const { slashingManager } = await deployAll(); + const { slashingManager } = await networkHelpers.loadFixture(deployAll); const target = ethers.Wallet.createRandom().address; await expect(slashingManager.setBondingRegistry(target)).to.emit( slashingManager, @@ -306,7 +306,7 @@ describe("Governance — access control, bounds & events", function () { describe("SortitionCommitteeFinalized event rename", function () { it("ABI exposes SortitionCommitteeFinalized but not CommitteeFinalized", async function () { - const { ciphernodeRegistry } = await deployAll(); + const { ciphernodeRegistry } = await networkHelpers.loadFixture(deployAll); expect( ciphernodeRegistry.interface.getEvent("SortitionCommitteeFinalized"), ).to.not.equal(null); @@ -320,7 +320,7 @@ describe("Governance — access control, bounds & events", function () { describe("active parameter set", function () { it("is append-only", async function () { - const { interfold } = await deployAll(); + const { interfold } = await networkHelpers.loadFixture(deployAll); await expect(interfold.setParamSet(0, BFV_PARAMS_DEFAULT)) .to.be.revertedWithCustomError(interfold, "ParamSetAlreadyRegistered") .withArgs(0); diff --git a/packages/interfold-dashboard/package.json b/packages/interfold-dashboard/package.json index 4a7b6dcea0..69b54c1d83 100644 --- a/packages/interfold-dashboard/package.json +++ b/packages/interfold-dashboard/package.json @@ -6,6 +6,7 @@ "description": "Interfold / CRISP public observation dashboard", "license": "LGPL-3.0-only", "scripts": { + "test": "vitest --run", "dev": "vite", "build": "vite build", "preview": "vite preview", @@ -18,6 +19,7 @@ "viem": "^2.21.0" }, "devDependencies": { + "vitest": "1.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/packages/interfold-dashboard/src/lib/e3.ts b/packages/interfold-dashboard/src/lib/e3.ts index 8d1eb2d657..1a6a946268 100644 --- a/packages/interfold-dashboard/src/lib/e3.ts +++ b/packages/interfold-dashboard/src/lib/e3.ts @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. // On-chain E3 fetchers — read events + view functions and assemble dashboard records. +import { CanonicalEventHistory, type HistorySnapshot, type IndexedLog } from './event-history' import { CONTRACTS, DEPLOY_BLOCK, E3Stage, TIMEOUTS, ciphernodeRegistryAbi, interfoldAbi, publicClient } from './chain' // Helper: pull a single named event ABI item out of the typechain bundle. @@ -61,8 +62,15 @@ const CRISP_GET_ROUND_DATA = { ], } as const -// Public RPCs cap getLogs range. 9_500 keeps us safely under common 10k limits. -const LOG_CHUNK = 9_500n +const history = new CanonicalEventHistory( + { + getBlock: (args) => publicClient.getBlock(args), + getLogs: (args) => publicClient.getLogs(args as any), + }, + `${publicClient.chain?.id}:${DEPLOY_BLOCK}:${CONTRACTS.Interfold}:${CONTRACTS.CiphernodeRegistry}:${CONTRACTS.CRISPProgram}`, +) + +const isTerminalStage = (stage: number) => stage === E3Stage.Complete || stage === E3Stage.Failed // An E3 is a CRISP poll only if its program contract is the CRISPProgram. // Other E3s on the same Interfold deployment run different programs and must not @@ -163,8 +171,8 @@ export type E3FullDetails = E3Summary & { // Aggregated inputs. inputsTracked is true only for programs whose input // event we understand (CRISP); for other programs inputs aren't observable // from the dashboard, so ballotCount is 0 and inputsTracked is false. - // ballotCount is the number of DISTINCT ballots (re-votes are not counted - // twice). ballotEvents holds the raw on-chain events (incl. re-votes). + // Each accepted input, including a re-vote, has a distinct tree index. + // Replayed copies of the same event count once. inputsTracked: boolean ballotCount: number ballotEvents: Array<{ @@ -185,13 +193,17 @@ export type E3FullDetails = E3Summary & { } // Resolve unix timestamps for a (small, bounded) set of block numbers, deduped. -async function blockTimestamps(blocks: bigint[]): Promise> { +async function blockTimestamps(snapshot: HistorySnapshot, blocks: bigint[]): Promise> { const uniq = Array.from(new Set(blocks.filter((b) => b > 0n).map((b) => b.toString()))) const entries = await Promise.all( uniq.map(async (s) => { try { + const cached = snapshot.get(`timestamp:${s}`) + if (cached !== undefined) return [s, cached] as const const b = await publicClient.getBlock({ blockNumber: BigInt(s) }) - return [s, Number(b.timestamp)] as const + const timestamp = Number(b.timestamp) + snapshot.set(`timestamp:${s}`, timestamp) + return [s, timestamp] as const } catch { return [s, 0] as const } @@ -200,18 +212,14 @@ async function blockTimestamps(blocks: bigint[]): Promise> { return new Map(entries) } -async function getLogsChunked( +async function getLogsChunked( + snapshot: HistorySnapshot, args: Omit[0], 'fromBlock' | 'toBlock'>, from: bigint, to: bigint, ): Promise { - const out: any[] = [] - for (let start = from; start <= to; start += LOG_CHUNK + 1n) { - const end = start + LOG_CHUNK > to ? to : start + LOG_CHUNK - const logs = await publicClient.getLogs({ ...args, fromBlock: start, toBlock: end } as any) - out.push(...logs) - } - return out as T[] + if (snapshot.head !== to) throw new Error('The event range does not match the snapshot.') + return snapshot.logs(args, from) } export async function fetchLatestBlock(): Promise { @@ -225,8 +233,10 @@ const BLOCKS_PER_DAY = 7200n export async function fetchRecentBallotCount(): Promise { const head = await fetchLatestBlock() const from = head > BLOCKS_PER_DAY + DEPLOY_BLOCK ? head - BLOCKS_PER_DAY : DEPLOY_BLOCK - const logs = await getLogsChunked({ address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, from, head) - return logs.length + return history.read(head, async (snapshot) => { + const logs = await getLogsChunked(snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, from, head) + return logs.length + }) } export type FetchE3Opts = { @@ -238,7 +248,13 @@ export type FetchE3Opts = { export async function fetchE3List(opts: FetchE3Opts = {}): Promise { const { crispOnly = false, toBlock } = opts const head = toBlock ?? (await fetchLatestBlock()) + return history.read(head, (snapshot) => fetchE3ListSnapshot(snapshot, crispOnly)) +} + +async function fetchE3ListSnapshot(snapshot: HistorySnapshot, crispOnly: boolean): Promise { + const head = snapshot.head const logs = await getLogsChunked( + snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_REQUESTED, @@ -249,26 +265,38 @@ export async function fetchE3List(opts: FetchE3Opts = {}): Promise const scoped = crispOnly ? logs.filter((log) => isCrispE3(log.args.e3.e3Program)) : logs - const [stages, ballotCounts] = await Promise.all([ + const active = scoped.filter((log) => snapshot.get(`stage:${log.args.e3Id}`) === undefined) + const [stageResults, ballotCounts] = await Promise.all([ // Current stage of each E3 in one multicall — lets the list show real status // (completed / failed / expired) rather than guessing. - (publicClient.multicall as any)({ - contracts: scoped.map((log) => ({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3Stage', - args: [log.args.e3Id], - })), - allowFailure: true, - }), + active.length + ? (publicClient.multicall as any)({ + blockNumber: head, + contracts: active.map((log) => ({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3Stage', + args: [log.args.e3Id], + })), + allowFailure: true, + }) + : Promise.resolve([]), // CRISP view: one scan of all ballots, grouped per E3 (distinct voteIndex), // so every history row shows its real count without a per-poll fetch. - crispOnly ? fetchCrispBallotCounts(head) : Promise.resolve(new Map()), + crispOnly ? fetchCrispBallotCounts(snapshot) : Promise.resolve(new Map()), ]) - const out: E3Summary[] = scoped.map((log, i) => { + const stages = new Map() + active.forEach((log, index) => { + const result = stageResults[index] + const stage = result.status === 'success' ? Number(result.result) : E3Stage.None + stages.set(log.args.e3Id.toString(), stage) + if (isTerminalStage(stage)) snapshot.set(`stage:${log.args.e3Id}`, stage) + }) + + const out: E3Summary[] = scoped.map((log) => { const { e3Id, e3 } = log.args - const stageResult = stages[i] + const stage = snapshot.get(`stage:${e3Id}`) ?? stages.get(e3Id.toString()) ?? E3Stage.None return { id: e3Id, e3Program: e3.e3Program, @@ -277,7 +305,7 @@ export async function fetchE3List(opts: FetchE3Opts = {}): Promise requestTxHash: log.transactionHash, inputWindow: [e3.inputWindow[0], e3.inputWindow[1]] as [bigint, bigint], committeeSize: Number(e3.committeeSize), - stage: stageResult.status === 'success' ? Number(stageResult.result) : E3Stage.None, + stage, ballotCount: ballotCounts.get(e3Id.toString()) ?? 0, } }) @@ -288,9 +316,10 @@ export async function fetchE3List(opts: FetchE3Opts = {}): Promise } // Distinct ballot count per CRISP E3, from a single scan of all InputPublished -// events grouped by e3Id (re-votes reuse a voteIndex, so we count unique ones). -async function fetchCrispBallotCounts(head: bigint): Promise> { - const inputs = await getLogsChunked({ address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, DEPLOY_BLOCK, head) +// events grouped by e3Id. Each accepted re-vote has its own index. +async function fetchCrispBallotCounts(snapshot: HistorySnapshot): Promise> { + const head = snapshot.head + const inputs = await getLogsChunked(snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, DEPLOY_BLOCK, head) const byE3 = new Map>() for (const l of inputs) { const id = l.args.e3Id.toString() @@ -303,42 +332,59 @@ async function fetchCrispBallotCounts(head: bigint): Promise export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise { const head = toBlock ?? (await fetchLatestBlock()) + return history.read(head, (snapshot) => fetchE3DetailsSnapshot(e3Id, snapshot)) +} + +async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): Promise { + const head = snapshot.head // 1. Pull live E3 struct + stage + currently-escrowed fee. const [e3, stage, feeEscrowed] = await Promise.all([ - (publicClient.readContract as any)({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3', - args: [e3Id], - }) as Promise, - (publicClient.readContract as any)({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3Stage', - args: [e3Id], - }) as Promise, + snapshot.get(`complete:${e3Id}`) ?? + ((publicClient.readContract as any)({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3', + args: [e3Id], + blockNumber: head, + }) as Promise), + snapshot.get(`stage:${e3Id}`) ?? + ((publicClient.readContract as any)({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3Stage', + args: [e3Id], + blockNumber: head, + }) as Promise), (publicClient.readContract as any)({ address: CONTRACTS.Interfold, abi: interfoldAbi, functionName: 'e3Payments', args: [e3Id], + blockNumber: head, }).catch(() => 0n) as Promise, ]) + if (isTerminalStage(stage)) snapshot.set(`stage:${e3Id}`, stage) + if (stage === E3Stage.Complete) snapshot.set(`complete:${e3Id}`, e3) + // CRISP round configuration. Only CRISP E3s expose it, and an uninitialised round // reports 0 options — in both cases the tally stays undecodable rather than guessed. const numOptions = isCrispE3(e3.e3Program) - ? await ((publicClient.readContract as any)({ + ? (snapshot.get(`options:${e3Id}`) ?? + (await ((publicClient.readContract as any)({ address: CONTRACTS.CRISPProgram, abi: [CRISP_GET_ROUND_DATA], functionName: 'getRoundData', args: [e3Id], + blockNumber: head, }) .then((data: readonly unknown[]) => Number(data[2] as bigint) || undefined) - .catch(() => undefined) as Promise) + .catch(() => undefined) as Promise))) : undefined + if (numOptions !== undefined) snapshot.set(`options:${e3Id}`, numOptions) + // `e3.requestBlock` is misnamed: on this contract version it stores // `block.timestamp` (EIP-6372 timestamp clock), not a block number. Using it // as fromBlock would push the scan range past chain head and silently miss @@ -347,6 +393,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_REQUESTED, @@ -365,6 +412,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.CiphernodeRegistry, event: REGISTRY_COMMITTEE_REQUESTED, @@ -374,6 +422,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.CiphernodeRegistry, event: REGISTRY_COMMITTEE_FINALIZED, @@ -383,6 +432,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_STAGE_CHANGED, @@ -408,6 +458,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED, @@ -418,6 +469,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_PLAINTEXT_PUBLISHED, @@ -427,6 +479,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( + snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_REWARDS_DISTRIBUTED, @@ -436,7 +489,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise l.args.index.toString())).size : 0 // Real committee reward total (sum of per-node amounts), once distributed. const committeeReward = rewards.length @@ -449,6 +502,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise 6) shownBallots.push(inputs[inputs.length - 1]) const ts = await blockTimestamps( + snapshot, [finLog?.blockNumber, pubLog?.blockNumber, resultLog?.blockNumber, ...shownBallots.map((l: any) => l.blockNumber)].filter( (b): b is bigint => typeof b === 'bigint', ), diff --git a/packages/interfold-dashboard/src/lib/event-history.ts b/packages/interfold-dashboard/src/lib/event-history.ts new file mode 100644 index 0000000000..1d8f77ff23 --- /dev/null +++ b/packages/interfold-dashboard/src/lib/event-history.ts @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +export type IndexedLog = { + blockNumber: bigint | null + blockHash: string | null + transactionHash: string | null + logIndex: number | null + removed?: boolean +} + +type HistoryClient = { + getBlock: (args: { blockNumber: bigint }) => Promise<{ hash: string | null }> + getLogs: (args: Record) => Promise +} +type Stream = { from: bigint; to: bigint; logs: IndexedLog[] } +const LOG_CHUNK = 9_500n + +function queryKey(value: unknown): string { + return JSON.stringify(value, (_, item) => { + if (typeof item === 'bigint') return { bigint: item.toString() } + if (item && typeof item === 'object' && !Array.isArray(item)) { + return Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, item[key]]), + ) + } + return item + }) +} + +export class HistorySnapshot { + constructor( + readonly head: bigint, + private client: HistoryClient, + private streams: Map, + private values: Map, + private checkCancelled: () => void, + ) {} + + get(key: string): T | undefined { + return this.values.get(key) as T | undefined + } + set(key: string, value: T) { + this.values.set(key, value) + } + + async logs(args: Record, from: bigint): Promise { + this.checkCancelled() + if (from > this.head) return [] + const key = queryKey(args) + const cached = this.streams.get(key) + let logs = cached?.logs ?? [] + const ranges: Array<[bigint, bigint]> = cached + ? [ + ...(from < cached.from ? [[from, cached.from - 1n] as [bigint, bigint]] : []), + ...(this.head > cached.to ? [[cached.to + 1n, this.head] as [bigint, bigint]] : []), + ] + : [[from, this.head]] + for (const [start, end] of ranges) { + const additions: IndexedLog[] = [] + for (let block = start; block <= end; block += LOG_CHUNK + 1n) { + this.checkCancelled() + const toBlock = block + LOG_CHUNK < end ? block + LOG_CHUNK : end + const result = await this.client.getLogs({ ...args, fromBlock: block, toBlock }) + for (const log of result) { + if (log.removed || log.blockNumber === null || !log.blockHash || !log.transactionHash || log.logIndex === null) { + throw new Error('The RPC returned an unconfirmed log. Retry the refresh.') + } + if (log.blockNumber >= block && log.blockNumber <= toBlock) additions.push(log) + } + } + logs = logs.concat(additions) + } + if (ranges.length) { + const unique = new Map(logs.map((log) => [`${log.blockHash}:${log.transactionHash}:${log.logIndex}`, log])) + logs = [...unique.values()].sort((a, b) => { + if (a.blockNumber !== b.blockNumber) return a.blockNumber! < b.blockNumber! ? -1 : 1 + return a.logIndex! - b.logIndex! + }) + this.streams.set(key, { from: cached && cached.from < from ? cached.from : from, to: this.head, logs }) + } + return logs.filter((log) => log.blockNumber! >= from && log.blockNumber! <= this.head) as T[] + } +} + +// One instance belongs to one client and deployment. Failed reads commit no cursors or values. +export class CanonicalEventHistory { + private streams = new Map() + private values = new Map() + private anchor?: { number: bigint; hash: string } + private queue: Promise = Promise.resolve() + private epoch = 0 + + constructor( + private client: HistoryClient, + readonly scope: string, + ) {} + + reset() { + this.epoch += 1 + this.streams.clear() + this.values.clear() + this.anchor = undefined + } + + read(head: bigint, work: (snapshot: HistorySnapshot) => Promise, signal?: AbortSignal): Promise { + const epoch = this.epoch + const run = async () => { + const checkCancelled = () => { + if (signal?.aborted || epoch !== this.epoch) throw new Error('The history refresh was cancelled.') + } + checkCancelled() + const block = await this.client.getBlock({ blockNumber: head }) + if (!block.hash) throw new Error('The requested block has no hash.') + let reset = this.anchor !== undefined && head < this.anchor.number + if (this.anchor && !reset) { + const oldHash = head === this.anchor.number ? block.hash : (await this.client.getBlock({ blockNumber: this.anchor.number })).hash + reset = oldHash !== this.anchor.hash + } + const streams = reset ? new Map() : new Map(this.streams) + const values = reset ? new Map() : new Map(this.values) + const snapshot = new HistorySnapshot(head, this.client, streams, values, checkCancelled) + const result = await work(snapshot) + checkCancelled() + const after = await this.client.getBlock({ blockNumber: head }) + if (after.hash !== block.hash) throw new Error('The chain changed during the refresh. Retry the refresh.') + checkCancelled() + this.streams = streams + this.values = values + this.anchor = { number: head, hash: block.hash } + return result + } + const result = this.queue.then(run, run) + this.queue = result.then( + () => undefined, + () => undefined, + ) + return result + } +} diff --git a/packages/interfold-dashboard/tests/e3-cache.test.ts b/packages/interfold-dashboard/tests/e3-cache.test.ts new file mode 100644 index 0000000000..df8a89461e --- /dev/null +++ b/packages/interfold-dashboard/tests/e3-cache.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlock: vi.fn(), + getLogs: vi.fn(), + multicall: vi.fn(), + readContract: vi.fn(), +})) +vi.mock('../src/lib/chain', () => ({ + CONTRACTS: { Interfold: '0x1', CiphernodeRegistry: '0x2', CRISPProgram: '0x3' }, + DEPLOY_BLOCK: 1n, + E3Stage: { None: 0, Requested: 1, CommitteeFinalized: 2, KeyPublished: 3, CiphertextReady: 4, Complete: 5, Failed: 6 }, + TIMEOUTS: { computeWindow: 10, decryptionWindow: 10 }, + interfoldAbi: ['E3Requested', 'PlaintextOutputPublished', 'RewardsDistributed', 'E3StageChanged'].map((name) => ({ + type: 'event', + name, + })), + ciphernodeRegistryAbi: ['CommitteeRequested', 'SortitionCommitteeFinalized'].map((name) => ({ type: 'event', name })), + publicClient: { ...mocks, chain: { id: 1 } }, +})) +const round = (id: bigint) => ({ + blockNumber: id, + blockHash: `hash:${id}`, + transactionHash: `tx:${id}`, + logIndex: 0, + args: { + e3Id: id, + e3: { + e3Program: '0x3', + requester: '0x4', + requestBlock: 1n, + inputWindow: [1n, 2n], + committeeSize: 1, + seed: 1n, + encryptionSchemeId: '0x', + committeePublicKey: '0x', + ciphertextOutput: '0x', + plaintextOutput: '0x1234', + }, + }, +}) + +beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + mocks.getBlock.mockImplementation(async ({ blockNumber }) => ({ hash: `hash:${blockNumber}`, timestamp: blockNumber })) + mocks.getLogs.mockImplementation(async ({ event, fromBlock, toBlock }) => + event.name === 'E3Requested' ? [round(1n), round(2n)].filter((log) => log.blockNumber >= fromBlock && log.blockNumber <= toBlock) : [], + ) + mocks.multicall.mockImplementation(async ({ contracts }) => + contracts.map(({ args }: any) => ({ status: 'success', result: args[0] === 1n ? 5 : 3 })), + ) +}) + +it('polls only nonterminal stages and fetches only the new event range', async () => { + const { fetchE3List } = await import('../src/lib/e3') + expect((await fetchE3List({ crispOnly: true, toBlock: 10n })).map((row) => row.stage)).toEqual([5, 3]) + await fetchE3List({ crispOnly: true, toBlock: 10n }) + expect(mocks.getLogs).toHaveBeenCalledTimes(2) + expect(mocks.multicall.mock.calls[1][0].contracts.map((contract: any) => contract.args[0])).toEqual([2n]) + expect(mocks.multicall.mock.calls[1][0].blockNumber).toBe(10n) + await fetchE3List({ crispOnly: true, toBlock: 12n }) + expect(mocks.getLogs.mock.calls.slice(2).every(([args]) => args.fromBlock === 11n && args.toBlock === 12n)).toBe(true) +}) + +it('re-reads terminal stages after a reorg', async () => { + const { fetchE3List } = await import('../src/lib/e3') + await fetchE3List({ toBlock: 10n }) + mocks.getBlock.mockImplementation(async ({ blockNumber }) => ({ hash: `replacement:${blockNumber}`, timestamp: blockNumber })) + mocks.multicall.mockImplementation(async ({ contracts }) => contracts.map(() => ({ status: 'success', result: 1 }))) + expect((await fetchE3List({ toBlock: 12n })).map((row) => row.stage)).toEqual([1, 1]) + expect(mocks.multicall.mock.calls[1][0].contracts).toHaveLength(2) + expect(mocks.getLogs.mock.calls[1][0].fromBlock).toBe(1n) +}) + +it('reuses completed result data and request history but refreshes the refundable balance', async () => { + mocks.readContract.mockImplementation(async ({ functionName }) => { + if (functionName === 'getE3') return round(1n).args.e3 + if (functionName === 'getE3Stage') return 5 + if (functionName === 'e3Payments') return 0n + if (functionName === 'getRoundData') return [0n, '0x', 2n] + throw new Error('Unexpected contract read') + }) + const { fetchE3Details, fetchE3List } = await import('../src/lib/e3') + await fetchE3List({ toBlock: 10n }) + const first = await fetchE3Details(1n, 10n) + const logCalls = mocks.getLogs.mock.calls.length + const readCalls = mocks.readContract.mock.calls.length + const second = await fetchE3Details(1n, 10n) + expect(second).toEqual(first) + expect(second.plaintextOutput).toBe('0x1234') + expect(mocks.getLogs).toHaveBeenCalledTimes(logCalls) + expect(mocks.readContract.mock.calls.slice(readCalls).map(([args]) => args.functionName)).toEqual(['e3Payments']) + expect(mocks.readContract.mock.calls.every(([args]) => args.blockNumber === 10n)).toBe(true) +}) diff --git a/packages/interfold-dashboard/tests/event-history.test.ts b/packages/interfold-dashboard/tests/event-history.test.ts new file mode 100644 index 0000000000..78e9bb2907 --- /dev/null +++ b/packages/interfold-dashboard/tests/event-history.test.ts @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { describe, expect, it, vi } from 'vitest' +import { CanonicalEventHistory, type IndexedLog } from '../src/lib/event-history' + +function fixture(scope = 'chain:deployment') { + const hashes = new Map() + const logs: IndexedLog[] = [] + const client = { + getBlock: vi.fn(async ({ blockNumber }: { blockNumber: bigint }) => ({ hash: hashes.get(blockNumber) ?? `hash:${blockNumber}` })), + getLogs: vi.fn(async ({ fromBlock, toBlock }: Record) => + logs.filter((log) => log.blockNumber! >= (fromBlock as bigint) && log.blockNumber! <= (toBlock as bigint)), + ), + } + const history = new CanonicalEventHistory(client, scope) + const read = (head: bigint, from = 1n) => history.read(head, (snapshot) => snapshot.logs({ address: 'contract', event: 'Event' }, from)) + return { client, history, hashes, logs, read } +} +const log = (blockNumber: bigint, logIndex = 0, blockHash = `hash:${blockNumber}`): IndexedLog => ({ + blockNumber, + logIndex, + blockHash, + transactionHash: `tx:${blockNumber}:${logIndex}`, +}) + +describe('canonical event history', () => { + it('loads history once, deduplicates replayed logs, and extends only after the cursor', async () => { + const f = fixture() + f.logs.push(log(2n), log(2n), log(4n)) + expect(await f.read(10n)).toEqual([log(2n), log(4n)]) + await f.read(10n) + expect(f.client.getLogs).toHaveBeenCalledTimes(1) + f.logs.push(log(11n)) + expect(await f.read(12n)).toEqual([log(2n), log(4n), log(11n)]) + expect(f.client.getLogs).toHaveBeenLastCalledWith({ address: 'contract', event: 'Event', fromBlock: 11n, toBlock: 12n }) + }) + + it('serializes overlapping refreshes without fetching the same range twice', async () => { + const f = fixture() + const [one, two] = await Promise.all([f.read(10n), f.read(12n)]) + expect(one).toEqual([]) + expect(two).toEqual([]) + expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ + [1n, 10n], + [11n, 12n], + ]) + }) + + it('does not advance a cursor or retain memoized values after a failed chunk', async () => { + const f = fixture() + f.client.getLogs.mockResolvedValueOnce([log(2n)]).mockRejectedValueOnce(new Error('Unavailable')) + await expect( + f.history.read(20_000n, async (snapshot) => { + snapshot.set('complete', true) + return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) + }), + ).rejects.toThrow('Unavailable') + expect( + await f.history.read(20_000n, async (snapshot) => { + expect(snapshot.get('complete')).toBeUndefined() + return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) + }), + ).toEqual([]) + expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ + [1n, 9_501n], + [9_502n, 19_002n], + [1n, 9_501n], + [9_502n, 19_002n], + [19_003n, 20_000n], + ]) + }) + + it('rebuilds after a reorg deeper than the polling window and invalidates terminal values', async () => { + const f = fixture() + f.logs.push(log(2n)) + await f.history.read(10_000n, async (snapshot) => { + snapshot.set('complete', true) + return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) + }) + f.hashes.set(10_000n, 'replacement ancestor') + f.logs.splice(0, 1, log(2n, 0, 'replacement block')) + expect( + await f.history.read(10_010n, async (snapshot) => { + expect(snapshot.get('complete')).toBeUndefined() + return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) + }), + ).toEqual([log(2n, 0, 'replacement block')]) + expect(f.client.getLogs.mock.calls[2][0].fromBlock).toBe(1n) + }) + + it('detects a replacement at the same height and a chain that moves during a read', async () => { + const f = fixture() + await f.read(10n) + f.hashes.set(10n, 'replacement') + await f.read(10n) + expect(f.client.getLogs).toHaveBeenCalledTimes(2) + f.client.getLogs.mockImplementationOnce(async () => { + f.hashes.set(12n, 'changed mid-read') + return [] + }) + await expect(f.read(12n)).rejects.toThrow('chain changed') + await f.read(12n) + expect(f.client.getLogs.mock.calls.slice(-2).map(([args]) => args.fromBlock)).toEqual([11n, 11n]) + }) + + it('prepends missing history and supports a moving recent-events window', async () => { + const f = fixture() + f.logs.push(log(3n), log(8n), log(11n)) + expect(await f.read(10n, 7n)).toEqual([log(8n)]) + expect(await f.read(12n, 8n)).toEqual([log(8n), log(11n)]) + expect(await f.read(12n, 1n)).toEqual([log(3n), log(8n), log(11n)]) + expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ + [7n, 10n], + [11n, 12n], + [1n, 6n], + ]) + }) + + it('keeps chains, deployments, event arguments, and historical views separate', async () => { + const one = fixture('one') + const two = fixture('two') + one.logs.push(log(8n)) + two.logs.push(log(9n)) + expect(await one.read(10n)).toEqual([log(8n)]) + expect(await two.read(10n)).toEqual([log(9n)]) + await one.history.read(10n, (snapshot) => snapshot.logs({ address: 'another', event: 'Event', args: { id: 5n } }, 1n)) + expect(one.client.getLogs).toHaveBeenCalledTimes(2) + expect(await one.read(5n)).toEqual([]) + expect(one.client.getLogs).toHaveBeenCalledTimes(3) + }) + + it('discards work interrupted by abort or reset and permits a later retry', async () => { + const f = fixture() + const abort = new AbortController() + await expect( + f.history.read( + 10n, + async (snapshot) => { + await snapshot.logs({ address: 'contract', event: 'Event' }, 1n) + abort.abort() + }, + abort.signal, + ), + ).rejects.toThrow('cancelled') + await f.read(10n) + expect(f.client.getLogs).toHaveBeenCalledTimes(2) + await expect( + f.history.read(12n, async () => { + f.history.reset() + }), + ).rejects.toThrow('cancelled') + await f.read(12n) + expect(f.client.getLogs).toHaveBeenLastCalledWith({ address: 'contract', event: 'Event', fromBlock: 1n, toBlock: 12n }) + }) + + it('rejects unconfirmed and removed events', async () => { + const f = fixture() + f.client.getLogs.mockResolvedValueOnce([{ ...log(2n), blockHash: null }]) + await expect(f.read(10n)).rejects.toThrow('unconfirmed') + f.client.getLogs.mockResolvedValueOnce([{ ...log(2n), removed: true }]) + await expect(f.read(10n)).rejects.toThrow('unconfirmed') + await f.read(10n) + expect(f.client.getLogs.mock.calls.every(([args]) => args.fromBlock === 1n)).toBe(true) + }) +}) diff --git a/packages/interfold-react/package.json b/packages/interfold-react/package.json index 06d6cc250c..64ffc755d8 100644 --- a/packages/interfold-react/package.json +++ b/packages/interfold-react/package.json @@ -15,6 +15,7 @@ "dist" ], "scripts": { + "test": "vitest --run", "build": "tsup", "dev": "tsup --watch", "clean": "rm -rf dist", @@ -46,6 +47,9 @@ "viem": "2.30.6" }, "devDependencies": { + "vitest": "1.6.1", + "react-test-renderer": "18.3.1", + "@types/react-test-renderer": "^18.3.0", "@interfold/config": "workspace:*", "@types/react": "^18.2.0", "tsup": "^8.5.0", diff --git a/packages/interfold-react/src/useInterfoldSDK.ts b/packages/interfold-react/src/useInterfoldSDK.ts index f1920f1b7d..b9b8bd1c12 100644 --- a/packages/interfold-react/src/useInterfoldSDK.ts +++ b/packages/interfold-react/src/useInterfoldSDK.ts @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useWalletClient, usePublicClient } from 'wagmi' import { InterfoldSDK, @@ -74,73 +74,41 @@ export interface UseInterfoldSDKReturn { */ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKReturn => { const [sdk, setSdk] = useState(null) - const [isInitialized, setIsInitialized] = useState(false) const [error, setError] = useState(null) - const sdkRef = useRef(null) const publicClient = usePublicClient() const { data: walletClient } = useWalletClient() - const initializeSDK = useCallback(async () => { - try { - setError(null) - - if (!publicClient) { - throw new Error('Public client not available') - } + const { interfold, ciphernodeRegistry, feeToken } = config.contracts ?? {} + const { autoConnect, thresholdBfvParamsPresetName } = config - if (sdkRef.current) { - sdkRef.current.cleanup() - } + // Each effect owns one SDK instance and releases that instance on cleanup. + useEffect(() => { + // Mirror the external SDK lifecycle into React state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setSdk(null) + setError(null) + if (!autoConnect || !publicClient) return + try { const sdkConfig: SDKConfig = { publicClient, walletClient, - contracts: config.contracts || { - interfold: '0x0000000000000000000000000000000000000000', - ciphernodeRegistry: '0x0000000000000000000000000000000000000000', - feeToken: '0x0000000000000000000000000000000000000000', + contracts: { + interfold: interfold ?? '0x0000000000000000000000000000000000000000', + ciphernodeRegistry: ciphernodeRegistry ?? '0x0000000000000000000000000000000000000000', + feeToken: feeToken ?? '0x0000000000000000000000000000000000000000', }, - thresholdBfvParamsPresetName: config.thresholdBfvParamsPresetName, + thresholdBfvParamsPresetName, } - - const newSdk = new InterfoldSDK(sdkConfig) - setSdk(newSdk) - sdkRef.current = newSdk - setIsInitialized(true) + const instance = new InterfoldSDK(sdkConfig) + setSdk(instance) + return () => instance.cleanup() } catch (err: unknown) { - const errorMessage = err instanceof SDKError ? `SDK Error (${err.code}): ${err.message}` : `Failed to initialize SDK: ${err}` - setError(errorMessage) - console.error('SDK initialization failed:', err) - } - }, [publicClient, walletClient, config.contracts, config.thresholdBfvParamsPresetName]) - - // 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]) - - // 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]) - - // Cleanup on unmount - useEffect(() => { - return () => { - if (sdkRef.current) { - sdkRef.current.cleanup() - } + const message = err instanceof SDKError ? `SDK Error (${err.code}): ${err.message}` : `Failed to initialize SDK: ${err}` + setError(message) } - }, []) + }, [autoConnect, publicClient, walletClient, interfold, ciphernodeRegistry, feeToken, thresholdBfvParamsPresetName]) const getThresholdBfvParamsSet = useCallback(async () => { if (!sdk) throw new Error('SDK not initialized') @@ -173,7 +141,7 @@ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKR return { sdk, - isInitialized, + isInitialized: sdk !== null, error, requestE3, getThresholdBfvParamsSet, diff --git a/packages/interfold-react/tests/useInterfoldSDK.test.ts b/packages/interfold-react/tests/useInterfoldSDK.test.ts new file mode 100644 index 0000000000..ea0cdb89c9 --- /dev/null +++ b/packages/interfold-react/tests/useInterfoldSDK.test.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createElement, useLayoutEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { useInterfoldSDK, type UseInterfoldSDKConfig } from '../src/useInterfoldSDK' + +const mocks = vi.hoisted(() => ({ + publicClient: {} as object | undefined, + walletClient: {} as object | undefined, + instances: [] as { cleanup: ReturnType; config: unknown }[], + fail: false, +})) +vi.mock('wagmi', () => ({ + usePublicClient: () => mocks.publicClient, + useWalletClient: () => ({ data: mocks.walletClient }), +})) +vi.mock('@interfold/sdk', () => ({ + InterfoldSDK: class { + cleanup = vi.fn() + constructor(readonly config: unknown) { + if (mocks.fail) throw new Error('Constructor failed') + mocks.instances.push(this) + } + }, + SDKError: class extends Error {}, + InterfoldEventType: {}, + RegistryEventType: {}, +})) + +let renderer: ReactTestRenderer | undefined +let result: ReturnType +const contracts = { + interfold: '0x1111', + ciphernodeRegistry: '0x2222', + feeToken: '0x3333', +} as const +function Probe({ config = {} }: { config?: Partial }) { + const value = useInterfoldSDK({ autoConnect: true, contracts: { ...contracts }, ...config }) + useLayoutEffect(() => { + result = value + }) + return null +} +const render = (config?: Partial) => + act(() => { + const element = createElement(Probe, { config }) + if (renderer) renderer.update(element) + else renderer = create(element) + }) + +beforeEach(() => { + mocks.publicClient = {} + mocks.walletClient = {} + mocks.instances.length = 0 + mocks.fail = false +}) +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +describe('SDK lifecycle', () => { + it('retains the instance and subscriptions for identical inline configuration', () => { + render() + const sdk = result.sdk + for (let i = 0; i < 5; i++) render() + expect(result.sdk).toBe(sdk) + expect(result.isInitialized).toBe(true) + expect(mocks.instances).toHaveLength(1) + expect(mocks.instances[0].cleanup).not.toHaveBeenCalled() + }) + + it('releases each old instance once on wallet change, disconnect, and unmount', () => { + render() + mocks.walletClient = {} + render() + mocks.walletClient = undefined + render() + expect(mocks.instances).toHaveLength(3) + expect(mocks.instances[2].config).toMatchObject({ walletClient: undefined }) + act(() => renderer!.unmount()) + renderer = undefined + for (const instance of mocks.instances) expect(instance.cleanup).toHaveBeenCalledTimes(1) + }) + + it('reacts to address, preset, and public-client changes', () => { + render() + render({ contracts: { ...contracts, interfold: '0x4444' } }) + render({ thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512' }) + mocks.publicClient = {} + render({ thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512' }) + expect(mocks.instances).toHaveLength(4) + expect(mocks.instances.slice(0, 3).every((instance) => instance.cleanup.mock.calls.length === 1)).toBe(true) + }) + + it('clears initialized state on disabled connection, missing client, or constructor failure', () => { + render() + render({ autoConnect: false }) + expect(result.sdk).toBeNull() + render() + mocks.publicClient = undefined + render() + expect(result.isInitialized).toBe(false) + mocks.publicClient = {} + mocks.fail = true + render() + expect(result.sdk).toBeNull() + expect(result.error).toContain('Constructor failed') + expect(mocks.instances).toHaveLength(2) + for (const instance of mocks.instances) expect(instance.cleanup).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/interfold-react/vitest.config.ts b/packages/interfold-react/vitest.config.ts new file mode 100644 index 0000000000..b30d93c0a1 --- /dev/null +++ b/packages/interfold-react/vitest.config.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: LGPL-3.0-only +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + resolve: { + alias: { '@interfold/sdk': fileURLToPath(new URL('../interfold-sdk/src/index.ts', import.meta.url)) }, + }, + test: { include: ['tests/**/*.test.ts'] }, +}) diff --git a/packages/interfold-sdk/package.json b/packages/interfold-sdk/package.json index eb71440323..3fa479ed8f 100644 --- a/packages/interfold-sdk/package.json +++ b/packages/interfold-sdk/package.json @@ -46,7 +46,8 @@ "dev": "tsup --watch", "clean": "rm -rf dist", "test": "vitest --run", - "pretest": "pnpm compile:circuits", + "test:proofs": "pnpm compile:circuits && pnpm test:proofs:prepared", + "test:proofs:prepared": "vitest --run --config vitest.proofs.config.ts", "prerelease": "pnpm clean && pnpm build", "release": "pnpm publish --access=public" }, diff --git a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts index 0efbc98993..cbfa1359a1 100644 --- a/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts +++ b/packages/interfold-sdk/src/circuits/assert-minimum-circuits.ts @@ -20,27 +20,14 @@ export const SDK_CIRCUIT_COMMITTEE = 'minimum' // runtime (not in browsers or web workers, even when `process` is polyfilled). const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null -let checked = false - /** - * SDK encryption artifacts are built for the minimum committee preset by default. - * Fail fast when `circuits/bin/.active-preset.json` points at another committee - * (e.g. after benchmark runs with `--committee small`). - * - * In browser environments this is a no-op (circuit files don't exist client-side). - * - * The Node-only check runs asynchronously (fire-and-forget) so this function can - * stay synchronous for its module-load-time caller while keeping the browser - * bundle free of Node builtins. In Node a mismatch surfaces as an unhandled - * rejection, which still terminates the process — preserving the fail-fast. + * Check the local SDK artifact selection before proof generation. + * Browser bundles contain their artifacts and do not use the local stamp. + * Await this check so a missing or mismatched stamp rejects the proof request. */ -export function assertSdkMinimumCircuits(): void { - if (checked || !isNode) { - checked = true - return - } - checked = true - void assertNodeCircuits() +export async function assertSdkMinimumCircuits(): Promise { + if (!isNode) return + await assertNodeCircuits() } async function assertNodeCircuits(): Promise { @@ -84,9 +71,10 @@ async function assertNodeCircuits(): Promise { ) } - let active: { committee?: string } + let active: { committee?: string; preset?: string } try { - active = JSON.parse(raw) as { committee?: string } + active = JSON.parse(raw) as { committee?: string; preset?: string } + if (active === null || typeof active !== 'object' || Array.isArray(active)) throw new Error('Invalid stamp object') } catch { throw new SDKError( `Could not parse ${activePresetPath} — run \`pnpm -C packages/interfold-sdk compile:circuits\`.`, @@ -101,4 +89,8 @@ async function assertNodeCircuits(): Promise { 'SDK_CIRCUIT_COMMITTEE_MISMATCH', ) } + + if (active.preset !== 'insecure-512') { + throw new SDKError('SDK encryption circuits require the insecure-512 preset.', 'SDK_CIRCUIT_PRESET_MISMATCH') + } } diff --git a/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts b/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts new file mode 100644 index 0000000000..baaac17d87 --- /dev/null +++ b/packages/interfold-sdk/src/crypto/user-data-encryption-prover.ts @@ -0,0 +1,94 @@ +// 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 { Barretenberg, UltraHonkBackend, type ProofData } from '@aztec/bb.js' +import userDataEncryptionCt0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' +import userDataEncryptionCt1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' +import userDataEncryptionCircuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' +import { CompiledCircuit, Noir } from '@noir-lang/noir_js' +import { proofToFields } from '../utils' + +import type { CircuitInputs } from './user-data-encryption' + +export const proveUserDataEncryption = async (circuitInputs: CircuitInputs): Promise => { + const api = await Barretenberg.new() + + try { + await api.initSRSChonk(2 ** 21) // fold circuit needs 2^21 points; default is 2^20 + + const { witness: userDataEncryptionCt0Witness } = await executeCircuit(userDataEncryptionCt0Circuit as CompiledCircuit, { + pk0is: circuitInputs.pk0is, + ct0is: circuitInputs.ct0is, + u: circuitInputs.u, + e0: circuitInputs.e0, + e0is: circuitInputs.e0is, + e0_quotients: circuitInputs.e0_quotients, + k1: circuitInputs.k1, + r1is: circuitInputs.r1is, + r2is: circuitInputs.r2is, + }) + const { witness: userDataEncryptionCt1Witness } = await executeCircuit(userDataEncryptionCt1Circuit as CompiledCircuit, { + pk1is: circuitInputs.pk1is, + ct1is: circuitInputs.ct1is, + u: circuitInputs.u, + e1: circuitInputs.e1, + p1is: circuitInputs.p1is, + p2is: circuitInputs.p2is, + }) + + const userDataEncryptionCt0Backend = new UltraHonkBackend((userDataEncryptionCt0Circuit as CompiledCircuit).bytecode, api) + const userDataEncryptionCt1Backend = new UltraHonkBackend((userDataEncryptionCt1Circuit as CompiledCircuit).bytecode, api) + + const { proof: userDataEncryptionCt0Proof, publicInputs: userDataEncryptionCt0PublicInputs } = + await userDataEncryptionCt0Backend.generateProof(userDataEncryptionCt0Witness, { + verifierTarget: 'noir-recursive-no-zk', + }) + const { proof: userDataEncryptionCt1Proof, publicInputs: userDataEncryptionCt1PublicInputs } = + await userDataEncryptionCt1Backend.generateProof(userDataEncryptionCt1Witness, { + verifierTarget: 'noir-recursive-no-zk', + }) + + const userDataEncryptionCt0Artifacts = await userDataEncryptionCt0Backend.generateRecursiveProofArtifacts( + userDataEncryptionCt0Proof, + userDataEncryptionCt0PublicInputs.length, + { + verifierTarget: 'noir-recursive-no-zk', + }, + ) + const userDataEncryptionCt1Artifacts = await userDataEncryptionCt1Backend.generateRecursiveProofArtifacts( + userDataEncryptionCt1Proof, + userDataEncryptionCt1PublicInputs.length, + { + verifierTarget: 'noir-recursive-no-zk', + }, + ) + + const { witness: userDataEncryptionWitness } = await executeCircuit(userDataEncryptionCircuit as CompiledCircuit, { + ct0_verification_key: userDataEncryptionCt0Artifacts.vkAsFields, + ct0_proof: proofToFields(userDataEncryptionCt0Proof), + ct0_public_inputs: userDataEncryptionCt0PublicInputs, + ct0_key_hash: userDataEncryptionCt0Artifacts.vkHash, + ct1_verification_key: userDataEncryptionCt1Artifacts.vkAsFields, + ct1_proof: proofToFields(userDataEncryptionCt1Proof), + ct1_public_inputs: userDataEncryptionCt1PublicInputs, + ct1_key_hash: userDataEncryptionCt1Artifacts.vkHash, + }) + + const userDataEncryptionBackend = new UltraHonkBackend((userDataEncryptionCircuit as CompiledCircuit).bytecode, api) + + return await userDataEncryptionBackend.generateProof(userDataEncryptionWitness, { + verifierTarget: 'noir-recursive-no-zk', + }) + } finally { + api.destroy() + } +} + +const executeCircuit = async (circuit: CompiledCircuit, inputs: any): Promise<{ witness: Uint8Array; returnValue: any }> => { + const noir = new Noir(circuit as CompiledCircuit) + + return noir.execute(inputs) +} diff --git a/packages/interfold-sdk/src/crypto/user-data-encryption.ts b/packages/interfold-sdk/src/crypto/user-data-encryption.ts index d043414d0d..0f2c42bd0a 100644 --- a/packages/interfold-sdk/src/crypto/user-data-encryption.ts +++ b/packages/interfold-sdk/src/crypto/user-data-encryption.ts @@ -4,123 +4,39 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { Barretenberg, UltraHonkBackend, type ProofData } from '@aztec/bb.js' -import userDataEncryptionCt0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' -import userDataEncryptionCt1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' -import userDataEncryptionCircuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' -import { CompiledCircuit, Noir } from '@noir-lang/noir_js' +import type { ProofData } from '@aztec/bb.js' import { assertSdkMinimumCircuits } from '../circuits/assert-minimum-circuits' -import { proofToFields } from '../utils' - -assertSdkMinimumCircuits() // Conversion to Noir types -export type Field = string +export type Field = string | number + +export interface PolynomialInput { + coefficients: Field[] +} /** * Describes the inputs to Greco circuit */ export interface CircuitInputs { - pk0is: string[][] - pk1is: string[][] - ct0is: string[][] - ct1is: string[][] - u: string[] - e0: string[] - e1: string[] - e0is: string[][] - e0_quotients: string[][] - k1: string[] - r1is: string[][] - r2is: string[][] - p1is: string[][] - p2is: string[][] - pk_commitment: string + pk0is: PolynomialInput[] + pk1is: PolynomialInput[] + ct0is: PolynomialInput[] + ct1is: PolynomialInput[] + u: PolynomialInput + e0: PolynomialInput + e1: PolynomialInput + e0is: PolynomialInput[] + e0_quotients: PolynomialInput[] + k1: PolynomialInput + r1is: PolynomialInput[] + r2is: PolynomialInput[] + p1is: PolynomialInput[] + p2is: PolynomialInput[] } -/** - * Generate a proof for a given circuit and circuit inputs - * @dev Defaults to the UltraHonkBackend - * @param circuitInputs - The circuit inputs - * @param circuit - The circuit - * @returns The proof - */ +/** Load the circuit artifacts only when a caller requests a proof. */ export const generateProof = async (circuitInputs: CircuitInputs): Promise => { - const api = await Barretenberg.new() - - try { - await api.initSRSChonk(2 ** 21) // fold circuit needs 2^21 points; default is 2^20 - - const { witness: userDataEncryptionCt0Witness } = await executeCircuit(userDataEncryptionCt0Circuit as CompiledCircuit, { - pk0is: circuitInputs.pk0is, - ct0is: circuitInputs.ct0is, - u: circuitInputs.u, - e0: circuitInputs.e0, - e0is: circuitInputs.e0is, - e0_quotients: circuitInputs.e0_quotients, - k1: circuitInputs.k1, - r1is: circuitInputs.r1is, - r2is: circuitInputs.r2is, - }) - const { witness: userDataEncryptionCt1Witness } = await executeCircuit(userDataEncryptionCt1Circuit as CompiledCircuit, { - pk1is: circuitInputs.pk1is, - ct1is: circuitInputs.ct1is, - u: circuitInputs.u, - e1: circuitInputs.e1, - p1is: circuitInputs.p1is, - p2is: circuitInputs.p2is, - }) - - const userDataEncryptionCt0Backend = new UltraHonkBackend((userDataEncryptionCt0Circuit as CompiledCircuit).bytecode, api) - const userDataEncryptionCt1Backend = new UltraHonkBackend((userDataEncryptionCt1Circuit as CompiledCircuit).bytecode, api) - - const { proof: userDataEncryptionCt0Proof, publicInputs: userDataEncryptionCt0PublicInputs } = - await userDataEncryptionCt0Backend.generateProof(userDataEncryptionCt0Witness, { - verifierTarget: 'noir-recursive-no-zk', - }) - const { proof: userDataEncryptionCt1Proof, publicInputs: userDataEncryptionCt1PublicInputs } = - await userDataEncryptionCt1Backend.generateProof(userDataEncryptionCt1Witness, { - verifierTarget: 'noir-recursive-no-zk', - }) - - const userDataEncryptionCt0Artifacts = await userDataEncryptionCt0Backend.generateRecursiveProofArtifacts( - userDataEncryptionCt0Proof, - userDataEncryptionCt0PublicInputs.length, - { - verifierTarget: 'noir-recursive-no-zk', - }, - ) - const userDataEncryptionCt1Artifacts = await userDataEncryptionCt1Backend.generateRecursiveProofArtifacts( - userDataEncryptionCt1Proof, - userDataEncryptionCt1PublicInputs.length, - { - verifierTarget: 'noir-recursive-no-zk', - }, - ) - - const { witness: userDataEncryptionWitness } = await executeCircuit(userDataEncryptionCircuit as CompiledCircuit, { - ct0_verification_key: userDataEncryptionCt0Artifacts.vkAsFields, - ct0_proof: proofToFields(userDataEncryptionCt0Proof), - ct0_public_inputs: userDataEncryptionCt0PublicInputs, - ct0_key_hash: userDataEncryptionCt0Artifacts.vkHash, - ct1_verification_key: userDataEncryptionCt1Artifacts.vkAsFields, - ct1_proof: proofToFields(userDataEncryptionCt1Proof), - ct1_public_inputs: userDataEncryptionCt1PublicInputs, - ct1_key_hash: userDataEncryptionCt1Artifacts.vkHash, - }) - - const userDataEncryptionBackend = new UltraHonkBackend((userDataEncryptionCircuit as CompiledCircuit).bytecode, api) - - return await userDataEncryptionBackend.generateProof(userDataEncryptionWitness, { - verifierTarget: 'noir-recursive-no-zk', - }) - } finally { - api.destroy() - } -} - -const executeCircuit = async (circuit: CompiledCircuit, inputs: any): Promise<{ witness: Uint8Array; returnValue: any }> => { - const noir = new Noir(circuit as CompiledCircuit) - - return noir.execute(inputs) + await assertSdkMinimumCircuits() + const { proveUserDataEncryption } = await import('./user-data-encryption-prover') + return proveUserDataEncryption(circuitInputs) } diff --git a/packages/interfold-sdk/tests/circuit-selection.test.ts b/packages/interfold-sdk/tests/circuit-selection.test.ts new file mode 100644 index 0000000000..78f1b1fa97 --- /dev/null +++ b/packages/interfold-sdk/tests/circuit-selection.test.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { assertSdkMinimumCircuits } from '../src/circuits/assert-minimum-circuits' + +const { readFileSync } = vi.hoisted(() => ({ readFileSync: vi.fn() })) +vi.mock('node:fs', async () => { + const fs = await vi.importActual('node:fs') + return { + ...fs, + readFileSync: (...args: Parameters) => + args[0].toString().endsWith('.active-preset.json') ? readFileSync(...args) : fs.readFileSync(...args), + } +}) + +describe('SDK circuit selection', () => { + beforeEach(() => { + readFileSync.mockReset() + }) + + it('accepts the supported preset and committee', async () => { + readFileSync.mockReturnValue(JSON.stringify({ preset: 'insecure-512', committee: 'minimum' })) + await expect(assertSdkMinimumCircuits()).resolves.toBeUndefined() + }) + + it('rejects missing artifacts through the awaited request', async () => { + readFileSync.mockImplementation(() => { + throw new Error('ENOENT') + }) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_STAMP_MISSING' }) + }) + + it.each(['{', 'null', '[]', '"invalid"'])('rejects invalid stamp %s', async (stamp) => { + readFileSync.mockReturnValue(stamp) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_STAMP_INVALID' }) + }) + + it.each(['micro', 'small', undefined])('rejects committee %s', async (committee) => { + readFileSync.mockReturnValue(JSON.stringify({ preset: 'insecure-512', committee })) + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_COMMITTEE_MISMATCH' }) + }) + + it('rechecks the selection after another build changes the stamp', async () => { + readFileSync.mockReturnValueOnce(JSON.stringify({ preset: 'insecure-512', committee: 'minimum' })) + readFileSync.mockReturnValueOnce(JSON.stringify({ preset: 'secure-8192', committee: 'minimum' })) + await assertSdkMinimumCircuits() + await expect(assertSdkMinimumCircuits()).rejects.toMatchObject({ code: 'SDK_CIRCUIT_PRESET_MISMATCH' }) + }) +}) diff --git a/packages/interfold-sdk/tests/integration/encryption-proof.test.ts b/packages/interfold-sdk/tests/integration/encryption-proof.test.ts new file mode 100644 index 0000000000..8eff34c0be --- /dev/null +++ b/packages/interfold-sdk/tests/integration/encryption-proof.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { Barretenberg, UltraHonkBackend, UltraHonkVerifierBackend, type ProofData } from '@aztec/bb.js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { bytesToBigInt, createPublicClient, http, toHex, zeroAddress } from 'viem' +import { hardhat } from 'viem/chains' +import { InterfoldSDK } from '../../src/interfold-sdk' +import circuit from '../../../../circuits/bin/threshold/target/user_data_encryption.json' +import ct0Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' +import ct1Circuit from '../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' + +const options = { verifierTarget: 'noir-recursive-no-zk' } as const +const sdk = new InterfoldSDK({ + publicClient: createPublicClient({ chain: hardhat, transport: http() }), + contracts: { interfold: zeroAddress, ciphernodeRegistry: zeroAddress, feeToken: zeroAddress }, + thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', +}) + +describe('real encryption proof', () => { + let api: Barretenberg | undefined + let verifier: UltraHonkVerifierBackend + let verificationKey: Uint8Array + let proof: ProofData + let publicKeyCommitment: bigint + let ciphertextCommitment: bigint + let innerKeyHashes: bigint[] + + beforeAll(async () => { + const publicKey = await sdk.generatePublicKey() + // Reuse one proof for positive and negative checks. Do not regenerate it per assertion. + const result = await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + proof = result.proof + publicKeyCommitment = bytesToBigInt(await sdk.computePublicKeyCommitment(publicKey)) + ciphertextCommitment = bytesToBigInt(await sdk.computeCiphertextCommitment(result.encryptedData)) + + api = await Barretenberg.new() + await api.initSRSChonk(2 ** 21) + verificationKey = await new UltraHonkBackend(circuit.bytecode, api).getVerificationKey(options) + verifier = new UltraHonkVerifierBackend(api) + innerKeyHashes = [] + for (const innerCircuit of [ct0Circuit, ct1Circuit]) { + const artifacts = await new UltraHonkBackend(innerCircuit.bytecode, api).generateRecursiveProofArtifacts(new Uint8Array(), 0, options) + innerKeyHashes.push(BigInt(artifacts.vkHash)) + } + }) + + afterAll(async () => { + await api?.destroy() + }) + + it('verifies against the compiled verification key and exact PK/ciphertext bindings', async () => { + expect(proof.publicInputs).toHaveLength(5) + expect(proof.publicInputs.slice(0, 4).map(BigInt)).toEqual([...innerKeyHashes, publicKeyCommitment, ciphertextCommitment]) + expect(await verifier.verifyProof({ ...proof, verificationKey }, options)).toBe(true) + }) + + it.each([0, 1, 2, 3, 4])('rejects an altered public input at position %i', async (index) => { + const publicInputs = [...proof.publicInputs] + publicInputs[index] = toHex(BigInt(publicInputs[index]) ^ 1n, { size: 32 }) + expect(await verifier.verifyProof({ ...proof, publicInputs, verificationKey }, options)).toBe(false) + }) + + it('rejects altered proof contents', async () => { + const altered = proof.proof.slice() + altered[altered.length - 1] ^= 1 + await expect(verifier.verifyProof({ ...proof, proof: altered, verificationKey }, options)).rejects.toThrow( + 'Deserialized point is not on the curve', + ) + }) +}) diff --git a/packages/interfold-sdk/tests/proof-api.test.ts b/packages/interfold-sdk/tests/proof-api.test.ts new file mode 100644 index 0000000000..359e6a8fea --- /dev/null +++ b/packages/interfold-sdk/tests/proof-api.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPublicClient, http, zeroAddress } from 'viem' +import { hardhat } from 'viem/chains' +import { InterfoldSDK } from '../src/interfold-sdk' +import { generateProof, type CircuitInputs } from '../src/crypto/user-data-encryption' + +// These tests check API forwarding. The integration suite verifies real proofs. +vi.mock('../src/crypto/user-data-encryption', () => ({ generateProof: vi.fn() })) + +const sdk = new InterfoldSDK({ + publicClient: createPublicClient({ chain: hardhat, transport: http() }), + contracts: { interfold: zeroAddress, ciphernodeRegistry: zeroAddress, feeToken: zeroAddress }, + thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512', +}) + +describe('proof API forwarding', () => { + let publicKey: Uint8Array + let expectedKeyInputs: Pick + let encodeCoefficient: (value: bigint) => bigint + const proof = { proof: new Uint8Array([1, 2, 3]), publicInputs: ['0x01'] } + + beforeAll(async () => { + publicKey = await sdk.generatePublicKey() + expectedKeyInputs = (await sdk.encryptNumberAndGenInputs(1n, publicKey)).circuitInputs + const params = await sdk.getThresholdBfvParamsSet() + const fieldModulus = 21888242871839275222246405745257275088548364400416034343698204186575808495617n + const qModT = params.moduli.reduce((product, modulus) => product * modulus, 1n) % params.plaintextModulus + encodeCoefficient = (value) => { + const residue = (qModT * value) % params.plaintextModulus + const centered = residue > params.plaintextModulus / 2n ? residue - params.plaintextModulus : residue + return (centered + fieldModulus) % fieldModulus + } + }) + + beforeEach(() => { + vi.mocked(generateProof).mockReset().mockResolvedValue(proof) + }) + + it.each(['number', 'vector'] as const)('forwards the %s witness and returns the proof unchanged', async (kind) => { + const result = + kind === 'number' + ? await sdk.encryptNumberAndGenProof(1n, publicKey) + : await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + + expect(generateProof).toHaveBeenCalledOnce() + const [inputs] = vi.mocked(generateProof).mock.calls[0] + expect(inputs.pk0is).toEqual(expectedKeyInputs.pk0is) + expect(inputs.pk1is).toEqual(expectedKeyInputs.pk1is) + const expectedPlaintext = Array(512).fill(0n) + expectedPlaintext[511] = encodeCoefficient(1n) + if (kind === 'vector') expectedPlaintext[510] = encodeCoefficient(2n) + expect(inputs.k1.coefficients.map(BigInt)).toEqual(expectedPlaintext) + expect(inputs.ct0is).toHaveLength(2) + expect(inputs.ct1is).toHaveLength(2) + expect(result.proof).toBe(proof) + expect(await sdk.computeCiphertextCommitment(result.encryptedData)).toHaveLength(32) + }) + + it.each(['number', 'vector'] as const)('propagates %s proof-generation failures', async (kind) => { + const failure = new Error('proof generation failed') + vi.mocked(generateProof).mockRejectedValueOnce(failure) + const request = + kind === 'number' + ? sdk.encryptNumberAndGenProof(1n, publicKey) + : sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) + await expect(request).rejects.toBe(failure) + }) +}) diff --git a/packages/interfold-sdk/tests/sdk.test.ts b/packages/interfold-sdk/tests/sdk.test.ts index 66947c1987..019cdbc322 100644 --- a/packages/interfold-sdk/tests/sdk.test.ts +++ b/packages/interfold-sdk/tests/sdk.test.ts @@ -4,13 +4,18 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { InterfoldSDK } from '../src/interfold-sdk' import { zeroAddress } from 'viem' import { hardhat } from 'viem/chains' import { generatePublicKey, encryptNumber as standaloneEncryptNumber, encryptVector as standaloneEncryptVector } from '../src/crypto' +let publicKey: Uint8Array +beforeAll(async () => { + publicKey = await generatePublicKey('INSECURE_THRESHOLD_512') +}) + describe('encryptNumber', () => { describe('trbfv', () => { // create SDK with default config @@ -27,31 +32,18 @@ describe('encryptNumber', () => { }) it('should encrypt a number without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() const value = await sdk.encryptNumber(10n, publicKey) expect(value).to.be.an.instanceof(Uint8Array) expect(value.length).to.equal(9_242) // TODO: test the encryption is correct }) - it('should encrypt a number and generate a proof without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() - - const value = await sdk.encryptNumberAndGenProof(1n, publicKey) - - expect(value).to.be.an.instanceof(Object) - expect(value.encryptedData).to.be.an.instanceof(Uint8Array) - expect(value.proof).to.be.an.instanceOf(Object) - }, 9999999) - it('should encrypt a vector of numbers without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() const value = await sdk.encryptVector(new BigUint64Array([1n, 2n]), publicKey) expect(value).to.be.an.instanceof(Uint8Array) expect(value.length).to.equal(9_242) }) it('should validate a committee public key against its on-chain commitment', async () => { - const publicKey = await sdk.generatePublicKey() const commitment = await sdk.computePublicKeyCommitment(publicKey) expect(await sdk.validatePublicKeyCommitment(publicKey, commitment)).to.equal(true) @@ -63,36 +55,23 @@ describe('encryptNumber', () => { }) it('should compute a SAFE commitment for encrypted data', async () => { - const publicKey = await sdk.generatePublicKey() const ciphertext = await sdk.encryptNumber(10n, publicKey) const commitment = await sdk.computeCiphertextCommitment(ciphertext) expect(commitment).to.be.an.instanceof(Uint8Array) expect(commitment.length).to.equal(32) }) - - it('should encrypt a vector and generate a proof without crashing in a node environent', async () => { - const publicKey = await sdk.generatePublicKey() - - const value = await sdk.encryptVectorAndGenProof(new BigUint64Array([1n, 2n]), publicKey) - - expect(value).to.be.an.instanceof(Object) - expect(value.encryptedData).to.be.an.instanceof(Uint8Array) - expect(value.proof).to.be.an.instanceOf(Object) - }, 9999999) }) describe('standalone encryption (no blockchain setup)', () => { it('should encrypt a number using standalone functions', async () => { - const pk = await generatePublicKey('INSECURE_THRESHOLD_512') - const ct = await standaloneEncryptNumber(10n, pk, 'INSECURE_THRESHOLD_512') + const ct = await standaloneEncryptNumber(10n, publicKey, 'INSECURE_THRESHOLD_512') expect(ct).to.be.an.instanceof(Uint8Array) expect(ct.length).to.equal(9_242) }) it('should encrypt a vector using standalone functions', async () => { - const pk = await generatePublicKey('INSECURE_THRESHOLD_512') - const ct = await standaloneEncryptVector(new BigUint64Array([1n, 2n]), pk, 'INSECURE_THRESHOLD_512') + const ct = await standaloneEncryptVector(new BigUint64Array([1n, 2n]), publicKey, 'INSECURE_THRESHOLD_512') expect(ct).to.be.an.instanceof(Uint8Array) expect(ct.length).to.equal(9_242) }) diff --git a/packages/interfold-sdk/vitest.config.ts b/packages/interfold-sdk/vitest.config.ts new file mode 100644 index 0000000000..6a083e8e3f --- /dev/null +++ b/packages/interfold-sdk/vitest.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/*.test.ts'], + }, +}) diff --git a/packages/interfold-sdk/vitest.proofs.config.ts b/packages/interfold-sdk/vitest.proofs.config.ts new file mode 100644 index 0000000000..ab7a9f0cdd --- /dev/null +++ b/packages/interfold-sdk/vitest.proofs.config.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/integration/*.test.ts'], + // Real recursive proofs share one worker to bound memory and setup cost. + poolOptions: { forks: { singleFork: true } }, + pool: 'forks', + hookTimeout: 600_000, + testTimeout: 120_000, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03ee51deac..d2c434c023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.11 version: 15.5.13 + '@types/react-test-renderer': + specifier: ^18.3.0 + version: 18.3.1 '@vitejs/plugin-react': specifier: ^4.2.1 version: 4.7.0(vite@5.4.21(@types/node@22.7.5)) @@ -240,6 +243,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.5.13 version: 0.5.14(@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.6.2))(prettier@3.6.2) + react-test-renderer: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.4.2 version: 3.4.19(tsx@4.20.6)(yaml@2.8.2) @@ -249,6 +255,9 @@ importers: vite: specifier: ^5.2.0 version: 5.4.21(@types/node@22.7.5) + vitest: + specifier: 1.6.1 + version: 1.6.1(@types/node@22.7.5) examples/CRISP/packages/crisp-contracts: dependencies: @@ -616,6 +625,9 @@ importers: vite: specifier: ^5.4.0 version: 5.4.21(@types/node@22.7.5) + vitest: + specifier: 1.6.1 + version: 1.6.1(@types/node@22.7.5) packages/interfold-mcp: dependencies: @@ -703,12 +715,21 @@ importers: '@types/react': specifier: ^18.2.0 version: 18.3.31 + '@types/react-test-renderer': + specifier: ^18.3.0 + version: 18.3.1 + react-test-renderer: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) tsup: specifier: 8.5.0 version: 8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: 1.6.1 + version: 1.6.1(@types/node@22.7.5) packages/interfold-sdk: dependencies: @@ -827,7 +848,7 @@ importers: version: 5.0.2(@openzeppelin/contracts@5.3.0) '@risc0/ethereum': specifier: file:lib/risc0-ethereum - version: risc0-ethereum@file:templates/default/lib/risc0-ethereum + version: file:templates/default/lib/risc0-ethereum '@types/chai': specifier: ^4.2.0 version: 4.3.20 @@ -3388,6 +3409,9 @@ packages: '@reown/appkit@1.7.8': resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} + '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': + resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -4475,6 +4499,9 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} + '@types/react-test-renderer@18.3.1': + resolution: {integrity: sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==} + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -9136,11 +9163,21 @@ packages: peerDependencies: react: '>=16.8' + react-shallow-renderer@16.15.0: + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-syntax-highlighter@15.6.6: resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==} peerDependencies: react: '>= 0.14.0' + react-test-renderer@18.3.1: + resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} + peerDependencies: + react: ^18.3.1 + react-transition-state@1.1.5: resolution: {integrity: sha512-ITY2mZqc2dWG2eitJkYNdcSFW8aKeOlkL2A/vowRrLL8GH3J6Re/SpD/BLvQzrVOTqjsP0b5S9N10vgNNzwMUQ==} peerDependencies: @@ -9368,9 +9405,6 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} - risc0-ethereum@file:templates/default/lib/risc0-ethereum: - resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} - robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -14141,6 +14175,8 @@ snapshots: - utf-8-validate - zod + '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/plugin-inject@5.0.5(rollup@4.62.3)': @@ -15408,6 +15444,10 @@ snapshots: dependencies: '@types/react': 18.3.31 + '@types/react-test-renderer@18.3.1': + dependencies: + '@types/react': 18.3.31 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -21568,6 +21608,12 @@ snapshots: '@remix-run/router': 1.23.3 react: 18.3.1 + react-shallow-renderer@16.15.0(react@18.3.1): + dependencies: + object-assign: 4.1.1 + react: 18.3.1 + react-is: 18.3.1 + react-syntax-highlighter@15.6.6(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 @@ -21578,6 +21624,13 @@ snapshots: react: 18.3.1 refractor: 3.6.0 + react-test-renderer@18.3.1(react@18.3.1): + dependencies: + react: 18.3.1 + react-is: 18.3.1 + react-shallow-renderer: 16.15.0(react@18.3.1) + scheduler: 0.23.2 + react-transition-state@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -21841,8 +21894,6 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - risc0-ethereum@file:templates/default/lib/risc0-ethereum: {} - robust-predicates@3.0.3: {} rollup@4.62.3: From 2ad80f8d13ea0b4af554319d188bda7028a65bb5 Mon Sep 17 00:00:00 2001 From: Hamza Khalid Date: Thu, 10 Sep 2026 22:18:45 +0500 Subject: [PATCH 2/2] chore: narrow PR to test changes --- .github/workflows/ci.yml | 29 -- TEST_AND_APP_OPTIMIZATION_REVIEW.md | 348 ------------------ agent/CONTEXT.md | 23 +- examples/CRISP/client/package.json | 4 - .../client/src/components/CircularTiles.tsx | 32 ++ .../client/src/components/CountdownTime.tsx | 56 ++- .../voteManagement/VoteManagement.context.tsx | 174 ++++----- .../client/src/hooks/generic/useFetchApi.tsx | 48 +-- .../src/hooks/interfold/useInterfoldServer.ts | 112 +++--- .../src/hooks/voting/useArchivePolls.ts | 54 --- examples/CRISP/client/src/model/poll.model.ts | 5 - .../client/src/pages/AllPolls/AllPolls.tsx | 68 ++-- .../src/pages/PollResult/PollResult.tsx | 21 +- .../client/src/pages/RoundPoll/RoundPoll.tsx | 19 +- .../client/src/utils/estimated-chain-clock.ts | 70 ---- .../tests/estimated-chain-clock.test.ts | 63 ---- .../client/tests/useArchivePolls.test.ts | 103 ------ .../CRISP/client/tests/useFetchApi.test.ts | 92 ----- examples/CRISP/client/vitest.config.ts | 8 - examples/CRISP/server/src/server/indexer.rs | 3 - examples/CRISP/server/src/server/models.rs | 44 --- examples/CRISP/server/src/server/repo.rs | 288 ++------------- .../CRISP/server/src/server/routes/state.rs | 191 +--------- .../server/tests/fixtures/round-index-v0.json | 1 - package.json | 6 +- packages/interfold-dashboard/package.json | 2 - packages/interfold-dashboard/src/lib/e3.ts | 150 +++----- .../src/lib/event-history.ts | 141 ------- .../tests/e3-cache.test.ts | 96 ----- .../tests/event-history.test.ts | 164 --------- packages/interfold-react/package.json | 4 - .../interfold-react/src/useInterfoldSDK.ts | 78 ++-- .../tests/useInterfoldSDK.test.ts | 112 ------ packages/interfold-react/vitest.config.ts | 10 - pnpm-lock.yaml | 63 +--- 35 files changed, 402 insertions(+), 2280 deletions(-) delete mode 100644 TEST_AND_APP_OPTIMIZATION_REVIEW.md create mode 100644 examples/CRISP/client/src/components/CircularTiles.tsx delete mode 100644 examples/CRISP/client/src/hooks/voting/useArchivePolls.ts delete mode 100644 examples/CRISP/client/src/utils/estimated-chain-clock.ts delete mode 100644 examples/CRISP/client/tests/estimated-chain-clock.test.ts delete mode 100644 examples/CRISP/client/tests/useArchivePolls.test.ts delete mode 100644 examples/CRISP/client/tests/useFetchApi.test.ts delete mode 100644 examples/CRISP/client/vitest.config.ts delete mode 100644 examples/CRISP/server/tests/fixtures/round-index-v0.json delete mode 100644 packages/interfold-dashboard/src/lib/event-history.ts delete mode 100644 packages/interfold-dashboard/tests/e3-cache.test.ts delete mode 100644 packages/interfold-dashboard/tests/event-history.test.ts delete mode 100644 packages/interfold-react/tests/useInterfoldSDK.test.ts delete mode 100644 packages/interfold-react/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ed017434..70baef5c84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,6 @@ jobs: net: ${{ steps.jobs.outputs.net }} init: ${{ steps.jobs.outputs.init }} build_sdk: ${{ steps.jobs.outputs.build_sdk }} - web_tests: ${{ steps.jobs.outputs.web_tests }} build_e3_support_dev: ${{ steps.jobs.outputs.build_e3_support_dev }} build_circuits: ${{ steps.jobs.outputs.build_circuits }} integration_prebuild: ${{ steps.jobs.outputs.integration_prebuild }} @@ -115,12 +114,6 @@ jobs: - 'crates/wasm/**' - '!**/*.md' - '!**/*.mdx' - web: - - 'packages/interfold-react/**' - - 'packages/interfold-dashboard/**' - - 'examples/CRISP/client/**' - - 'pnpm-lock.yaml' - - 'package.json' integration_tests: - 'tests/integration/**' - '!**/*.md' @@ -142,7 +135,6 @@ jobs: CRISP="${{ steps.filter.outputs.crisp }}" TEMPLATES="${{ steps.filter.outputs.templates }}" SDK="${{ steps.filter.outputs.sdk }}" - WEB="${{ steps.filter.outputs.web }}" INTEGRATION="${{ steps.filter.outputs.integration_tests }}" DOCKER="${{ steps.filter.outputs.docker }}" CI="${{ steps.filter.outputs.ci }}" @@ -154,7 +146,6 @@ jobs: echo "rust_integration_tests=$(any $FORCE $RUST $CONTRACTS $CIRCUITS $CI)" >> $GITHUB_OUTPUT echo "ciphernode_e2e=$(any $FORCE $RUST $CONTRACTS $CIRCUITS $INTEGRATION $CI)" >> $GITHUB_OUTPUT echo "build_sdk=$(any $FORCE $RUST $CONTRACTS $SDK $INTEGRATION $CIRCUITS $CI $TEMPLATES)" >> $GITHUB_OUTPUT - echo "web_tests=$(any $FORCE $WEB $SDK $CI)" >> $GITHUB_OUTPUT # CRISP jobs (unit legs and e2e alike) only guard CRISP's own layers. # Cross-cutting ciphernode coverage comes from template_integration, # which drives the same full E3 lifecycle with the same interfold @@ -1290,26 +1281,6 @@ jobs: retention-days: 1 if-no-files-found: error - web_tests: - needs: [detect_changes] - if: needs.detect_changes.outputs.web_tests == 'true' - timeout-minutes: 10 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Install pnpm - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' - cache-dependency-path: pnpm-lock.yaml - - name: Install test dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Test app behavior without circuit preparation - run: pnpm test:web - build_sdk: needs: [detect_changes] if: needs.detect_changes.outputs.build_sdk == 'true' diff --git a/TEST_AND_APP_OPTIMIZATION_REVIEW.md b/TEST_AND_APP_OPTIMIZATION_REVIEW.md deleted file mode 100644 index 36efa01988..0000000000 --- a/TEST_AND_APP_OPTIMIZATION_REVIEW.md +++ /dev/null @@ -1,348 +0,0 @@ -# Test quality and application performance review - -Date: 2026-09-08 - -## Scope and status - -This report records a repository-wide source scan and focused inspection of the suspect paths. The -reviewed checkout was `499146c971b0a4d65842a330ec9010b8dd091805`. Relevant findings were also -checked against the Avail candidate at `723bed2eeb93beb20900a444bf7c78aa2d8cff16`. - -The original review did not run runtime benchmarks or a complete test suite. The findings below -record the original behavior. The implementation section records subsequent fixes and local checks. -Generated verifiers and vendored dependencies were excluded from cleanup candidates. - -The main opportunities are stronger assertions, less repeated setup, and less repeated application -I/O. Test count alone does not measure useful coverage. - -## Implementation — 2026-09-10 - -All 12 findings are implemented on `fix/test-quality-and-app-performance`, originally created from -local `main` at `ab0ef64a83e113b951c94dd45ed31e730f6838b8`. The Jolt experiment is separate on -`feat/crisp-jolt-experiment` and is not part of this change. The results below describe local -checks, not a complete CI run or a deployment. - -| Finding | Change | Verification | -| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| T1 | One shared real encryption proof replaces two type-only proof tests. Separate wrapper tests check exact witness forwarding and error propagation. | The compiled verifier accepts the proof. Each of its five altered public inputs fails verification. Altered proof bytes fail decoding. | -| T2 | Required Rust proof and slashing targets fail on missing tools or artifacts. Ordinary runs report expensive integration tests as ignored. CI explicitly selects them. | All 8 fold tests, the correlated node proof, and 19 slashing tests pass. Seven slashing tests execute compiled contracts. | -| T3 | All 23 governance tests use the named deployment snapshot fixture. | The same assertions pass. Local suite time fell from about 8 seconds to 0.6 seconds. | -| T4 | Fast SDK tests no longer prepare circuits or load the prover. The SDK build prepares artifacts once for its CI job. | All 38 fast tests pass. The prepared proof command verifies real proofs without repeating preparation. | -| T5 | Utility tests compare a known Merkle root and exact signature components. Invalid-leaf checks remain. | All 7 utility tests pass. | -| T6 | Network tests wait for observable buffer state instead of sleeping. Delivery waits have explicit bounds. | All 5 event-buffer tests pass. | -| A1 | Dashboard event cursors advance incrementally. Completed state is cached, while fees and new reward events remain live. | 12 dashboard tests cover shared ranges, RPC counts, terminal state, reorgs, failed chunks, cancellation, and reset. | -| A2 | Countdown components share one clock per RPC client. Local ticks update the display between non-overlapping chain refreshes. | 3 clock tests cover shared requests, slow responses, retries, and cleanup. Contract deadlines remain authoritative. | -| A3 | The archive uses indexed requester positions and bounded server pages. The client fetches each page without an artificial delay. | Repository, HTTP, and hook tests cover legacy migration, replay, stable cursors, pending rows, full-width IDs, errors, and retry. | -| A4 | One effect owns each React SDK instance. Configuration values and client identities control its lifecycle. | 4 hook tests cover inline configuration, wallet changes, configuration changes, and cleanup. | -| C1 | The HTTP hook dispatches the requested Axios method, rejects failures, and counts concurrent requests. Endpoint names remain stable. | 9 hook tests cover methods, request options, suppressed 404 responses, rejected failures, and concurrent loading. | -| C2 | The unused `CircularTiles.tsx` component is removed after a reference check. | No application imports remain. Git retains the removed source. No bundle-size improvement is claimed. | - -### Proof preparation and repeated setup - -The fast wrapper suite mocks only the prover boundary. It still uses real WASM encryption and -compares the witness values. It does not claim cryptographic verification. The separate -[proof suite](packages/interfold-sdk/tests/integration/encryption-proof.test.ts) generates one -wrapper proof, which requires two inner proofs. All positive and negative assertions reuse it. - -Required Rust tests exposed stale fixture assumptions that the previous skip paths concealed. -Slashing tests now link the compiled evidence library and configure current E3 dependency snapshots. -A test-only bonding registry records requested penalties and lock release. These assertions test -slashing execution, not real token transfers. Existing production contracts are unchanged. - -The C3 fold fixture now uses the current minimum committee shape of six slots. A full circuit build -resolved locally mixed artifact shapes. No circuit, threshold, witness format, or proof algorithm -was changed. The circuit builder and its source checks remain unchanged. - -### App behavior and compatibility - -The dashboard validates the previous cached block hash before a refresh and the requested head hash -before committing results. A reorg invalidates event history and cached terminal state. Failed later -chunks commit neither earlier chunks nor cached values. A repeated head needs no new log requests. -Active stages still require contract reads. Terminal fees remain live. - -The archive endpoint is `POST /state/archive`. It accepts requester filters, a versioned cursor, and -a limit from 1 to 50 (default 12). It returns `items` and `next_cursor`. Requester filtering -precedes round reads. Each page reads at most `limit` round pairs, not every historical round. The -index itself remains one JSON record, so index deserialization still grows with archive size. - -Schema 1 adds requester positions without changing existing IDs or their order. Startup migrates -legacy indexes once. Failed migrations do not advance the schema version. New rounds do not shift an -existing cursor. Pending rounds consume positions but produce no summary. Empty pages can still have -a next cursor. The client retains that cursor and offers another page or retry. The legacy -`/state/all` endpoint remains available. - -### Local verification - -The counts below are command results, not a sum of independent coverage. Durations exclude setup and -compilation unless stated otherwise. They do not establish a CI-wide speedup. - -| Command | Result | -| ------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `pnpm evm:test test/Governance/AccessAndBounds.spec.ts` | 23 passed. About 8 seconds before fixture reuse, 0.6 seconds after. | -| `pnpm sdk:test` | 38 passed. Latest runner duration: 1.23 seconds, with no circuit preparation. | -| `pnpm sdk:test:proofs:prepared` | 7 passed. Latest proof-suite duration: 6.49 seconds. | -| `pnpm test:web` | 31 passed: React 4, dashboard 12, CRISP client 15. | -| `pnpm -C examples/CRISP test:sdk tests/utils.test.ts` | 7 passed. | -| `cargo test -p e3-net event_buffer -j 2` | 5 passed. | -| `pnpm rust:test:slashing` | 19 passed, including 7 contract-backed tests. No ignored tests. | -| `pnpm rust:test:proofs` | 8 fold tests and 1 correlated node test passed. No ignored tests. | -| `cargo test -p crisp --lib -j 2` in `examples/CRISP` | 117 passed, 6 existing external-RPC tests ignored. Includes the archive HTTP tests. | - -Root Rust checks used `CARGO_TARGET_DIR=examples/CRISP/target` and two build jobs to reuse the -working local compilation cache. `pnpm evm:build` and a full -`pnpm build:circuits --preset insecure-512 --committee minimum --skip-if-built` completed first. The -full circuit build produced all 24 circuits for one consistent pair. - -Scoped ESLint, client and dashboard TypeScript checks, SDK and React declaration builds, and -`git diff --check` pass. The committee, documentation, address, and invariant checks pass. The -initial license check reported the deleted `CircularTiles.tsx` because its tracked-file scan still -included the unstaged deletion. New source files include SPDX headers. - -Full `pnpm test`, the complete Noir test suite, live-RPC application benchmarks, and remote CI were -not run. The new fast web CI job does not prepare circuits. Existing prepared jobs now explicitly -run the required proof and slashing targets. - -## Test quality - -### T1. Expensive SDK proof tests do not verify proofs - -Source: [SDK tests](packages/interfold-sdk/tests/sdk.test.ts). - -The number and vector proof tests run the real proof-generation pipeline. They only check object -types and byte-array types. Neither test verifies the resulting proof or checks its public-input -binding. Each timeout is 9,999,999 milliseconds, almost 2 hours 47 minutes. - -The tests provide crash detection, but their assertions do not justify treating them as proof -correctness tests. - -Recommended changes: - -- Keep real proof generation in a dedicated cryptographic integration suite. -- Verify each proof against the expected public inputs and verification key. -- Reject altered commitments and public inputs in negative tests. -- Test API wrapper argument forwarding separately, without generating redundant proofs. -- Set explicit, justified integration timeouts. - -Acceptance: a valid proof passes, an altered binding fails, and a malformed proof object cannot -satisfy the test. - -Preserve the -[cryptographic compatibility unit](agent/INVARIANTS.md#noir--barretenberg-compatibility) and all -proof-binding requirements. - -### T2. Optional Rust integration tests can report success without running - -Sources: - -- [Fold tests](crates/zk-prover/tests/fold_accumulators_e2e_tests.rs) -- [Correlated fold tests](crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs) -- [Slashing integration tests](crates/zk-prover/tests/slashing_integration_tests.rs) -- [CI workflow](.github/workflows/ci.yml) - -Several tests print a skip message and return successfully when tools or artifacts are missing. -These test binaries are not among CI's explicit root integration-test targets. Other proof suites -are configured in CI, so this finding does not mean that CI runs no real proofs. - -Recommended changes: - -- Distinguish required integration tests from explicitly optional tests. -- Fail required jobs when a binary or artifact is missing. -- Use explicit test selection or ignored-test reporting for optional local tests. -- Check that every required integration-test target belongs to a CI job. - -Acceptance: missing prerequisites cannot produce a successful required proof or slashing job. Retain -the actual tests. They protect the repository's -[compatibility and evidence requirements](agent/INVARIANTS.md#meta-invariants). - -### T3. Governance tests repeat full-system deployment - -Source: -[Governance access and bounds tests](packages/interfold-contracts/test/Governance/AccessAndBounds.spec.ts). - -There are 23 direct calls to `deployAll()`. Each call deploys the protocol fixture before testing -ownership, limits, or configuration behavior. - -Recommended change: use a named `loadFixture` snapshot fixture, as other contract suites already do. -Keep the distinct ownership and bounds assertions. - -Acceptance: all assertions remain, and independent tests restore the same initial state. Measure -deployment count and suite duration before and after the change. - -### T4. SDK unit tests enter circuit preparation unnecessarily - -Sources: [SDK scripts](packages/interfold-sdk/package.json) and -[circuit compilation entry point](packages/interfold-sdk/scripts/compile-circuits.sh). - -The SDK `pretest` script invokes circuit preparation even for event-listener tests. The build -preparation invokes it too. The script does not request the source-checked `--skip-if-built` path. - -Recommended changes: - -- Separate event, contract-client, and wrapper tests from cryptographic integration tests. -- Hydrate and validate the required circuit artifacts once per cryptographic job. -- Keep source, preset, committee, compiler, and verification-key consistency checks. - -Acceptance: an event-only test does not invoke Nargo or Barretenberg. A cryptographic test fails -when its artifacts do not match the selected configuration. - -### T5. Weak utility tests overlap stronger neighboring tests - -Source: [CRISP SDK utility tests](examples/CRISP/packages/crisp-sdk/tests/utils.test.ts). - -One test checks only that a generated Merkle root exists. The neighboring proof test constructs the -tree and verifies a proof against it. The existence-only test adds little coverage. - -The signature-component test checks only four `Uint8Array` types, not their contents. - -Recommended changes: - -- Remove the existence-only test or replace it with a known-root vector. -- Check signature components against known expected values. -- Retain exact hash vectors, invalid-leaf tests, and server-format compatibility tests. - -Acceptance: incorrect root or signature contents fail even when the returned types are correct. - -### T6. Network tests depend on scheduling delays - -Source: [Network event-buffer tests](crates/net/src/event_buffer/tests.rs). - -`test_buffers_until_sync_ended` uses a 10-millisecond sleep and a 100-millisecond delivery timeout. -It also contains receives without a timeout. A regression can therefore hang the test, while a -loaded runner can miss a short delivery deadline. - -This is a timing risk found in source, not a reproduced flaky failure. - -Recommended changes: - -- Synchronize on observable actor progress instead of assuming that a sleep is sufficient. -- Give every receive a bounded failure path. -- Keep the assertions that events remain buffered until synchronization completes. - -Acceptance: the test rejects early delivery and lost delivery without depending on runner speed. -Preserve the [startup and replay ordering rules](agent/INVARIANTS.md#ordering-backpressure-effects). - -## Application performance - -### A1. The public dashboard repeatedly scans complete event history - -Sources: [Event queries](packages/interfold-dashboard/src/lib/e3.ts) and -[polling hooks](packages/interfold-dashboard/src/lib/useE3s.ts). - -The dashboard polls every 15 seconds. List refreshes scan E3 requests from the deployment block and -read stages for historical E3s. The CRISP view also scans historical ballots. Detail refreshes -repeat the request-history scan before querying the selected E3. - -Recommended changes: - -- Load history once and keep a cursor scoped to the chain and deployment. -- Fetch new events after the cursor. -- Cache immutable metadata and completed E3 results. -- Refresh active E3 state separately. -- Handle reorgs, overlapping ranges, duplicate events, and interrupted requests explicitly. - -Acceptance: a refresh with no new events does not rescan the deployment history. Reorg and replay -tests must still produce the correct view. Preserve -[stable event identity and replay semantics](agent/INVARIANTS.md#meta-invariants). - -### A2. The countdown requests a block every second - -Source: [CRISP countdown](examples/CRISP/client/src/components/CountdownTime.tsx). - -Each timer tick calls `getBlock()`. There is no in-flight guard, so slow requests can overlap. - -Recommended change: share the latest observed chain timestamp, advance the displayed estimate -locally, and refresh chain state periodically. Label the display as an estimate when needed. - -Acceptance: countdown ticks do not each require an RPC request. Transaction checks and acceptance -still use the [on-chain deadlines](agent/INVARIANTS.md#deadlines), not the browser clock. - -### A3. The poll archive delays display without fetching a new page - -Sources: [Archive page](examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx) and -[round-state routes](examples/CRISP/server/src/server/routes/state.rs). - -The archive already holds its results, then waits one second before increasing the visible slice. No -network request occurs inside that delay. The server reads all round records sequentially and -filters by requester after those reads. - -Recommended changes: - -- Remove the artificial one-second delay. -- Add server-side pagination and requester indexing. -- Return lightweight summaries for archive rows. - -Acceptance: already loaded rows appear without the delay. Fetching one archive page does not require -reading and returning every historical round. - -### A4. Inline configuration can repeatedly recreate the React SDK - -Source: [React SDK hook](packages/interfold-react/src/useInterfoldSDK.ts). - -The initialization callback depends on the identity of `config.contracts`. The documented inline -object changes identity on every render. With a connected wallet, initialization updates state and -can trigger another cleanup and initialization cycle. - -Recommended changes: - -- Key the lifecycle on stable configuration values and client identities. -- Use one initialization and cleanup effect. -- Test repeated renders with inline configuration, wallet changes, and unmounts. - -Acceptance: unchanged configuration does not reconstruct the SDK or discard its event subscriptions. -The existing template memoizes its configuration, so this finding does not claim that the template -currently enters an infinite loop. - -## Wrappers and unused code - -### C1. The generic HTTP wrapper hides method and error behavior - -Source: [CRISP HTTP hook](examples/CRISP/client/src/hooks/generic/useFetchApi.tsx). - -The wrapper accepts arbitrary Axios methods, but every method except lowercase `get` becomes POST. -It logs failures and returns `undefined`. One loading flag also represents concurrent requests. -Current endpoint callers mainly use GET and POST, so unsupported-method behavior is a latent API -defect rather than a demonstrated failing request. - -Recommended changes: - -- Narrow the supported methods or dispatch through the matching Axios request method. -- Preserve explicit error results or rejected promises. -- Track loading per request or per query. -- Keep useful domain-specific endpoint names. - -Acceptance: methods, failures, and concurrent loading states match the advertised API. - -### C2. CircularTiles has no application references - -Source: [CircularTiles](examples/CRISP/client/src/components/CircularTiles.tsx). - -No references were found, and the component is unreachable from the CRISP application's static -import graph. Remove it after a final reference check. This reduces maintenance clutter, not a -measured bundle size or runtime cost. - -## Code to retain - -- Commitment mismatch, replay, ordering, duplicate-event, timeout, and recovery tests. -- Rust, Solidity, and Noir tests that independently verify the same cross-language encoding. -- Actor and effect boundaries that enforce runtime architecture. -- Named contract fixtures that provide isolated protocol state. -- The node dashboard polling wrapper, which already handles cancellation, overlapping requests, and - background-tab polling. - -## Suggested implementation order - -1. Correct misleading test results and missing required integration targets. -2. Reuse contract fixtures and separate fast SDK tests from proof tests. -3. Correct the React SDK lifecycle and HTTP error behavior. -4. Remove repeated dashboard scans, countdown RPC calls, and artificial archive delays. -5. Remove confirmed dead code and redundant tests. -6. Compare test coverage, command timings, RPC counts, and application behavior before and after. - -No cleanup may silently change thresholds, commitments, proof multiplicity, witness formats, event -identity, or replay behavior. Such changes require their own compatibility review and tests. - -## Reference documentation - -- [React: unnecessary object dependencies](https://react.dev/reference/react/useEffect#removing-unnecessary-object-dependencies) -- [Hardhat network helpers](https://hardhat.org/docs/plugins/hardhat-network-helpers) -- [Repository invariants](agent/INVARIANTS.md) diff --git a/agent/CONTEXT.md b/agent/CONTEXT.md index 23d0a2e42b..477144ec47 100644 --- a/agent/CONTEXT.md +++ b/agent/CONTEXT.md @@ -59,9 +59,8 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Install / build all | `pnpm i` · `pnpm build` | | Build Rust | `pnpm rust:build` (cargo `--locked --release`; prebuilds EVM fixtures) | -| Test everything | `pnpm test` (EVM, Rust, required proof/slashing suites, SDK, web, Noir) | +| Test everything | `pnpm test` (EVM, Rust, required proof/slashing suites, SDK, Noir) | | Test one layer | `pnpm evm:test` · `pnpm rust:test` · `pnpm sdk:test` · `pnpm noir:test` | -| Fast app tests | `pnpm test:web` (React SDK, dashboard, CRISP client, no circuit preparation) | | SDK proof verification | `pnpm sdk:test:proofs` (prepare circuits, generate one proof, verify bindings and reject tampering) | | Prepared SDK proof tests | `pnpm sdk:test:proofs:prepared` (reuse the current SDK build or prepared circuit set) | | Rust proof integration | `pnpm rust:test:proofs` (prepared insecure-512/minimum circuits and `bb`) | @@ -75,7 +74,7 @@ Run from repo root via pnpm scripts — not raw cargo/nargo/hardhat. | Prepare release branch | `pnpm bump:versions X.Y.Z` | | Tag merged release | `pnpm release:tag X.Y.Z` from updated `main` | -## Test preparation and app reads +## Test preparation `pnpm sdk:test` runs the fast SDK suites without circuit preparation. The proof API tests mock the prover boundary. They do not claim to verify cryptographic proofs. The separate proof suite verifies @@ -89,24 +88,6 @@ consistent set of inner and recursive circuits. Before `pnpm rust:test:slashing` Ordinary Rust test runs report these integration tests as ignored. CI explicitly selects them. The full test command reuses the prepared circuits for SDK proof verification. -The dashboard keeps event cursors per client and deployment. It validates the previous block hash -before extending history. A reorg or an earlier requested height clears cached history and terminal -state. Failed or cancelled refreshes commit neither cursors nor cached values. Only on-chain -`Complete` and `Failed` stages stop stage polling. Display-time deadline estimates do not. - -CRISP serves archive pages at `POST /state/archive`. The request accepts `requesters`, an optional -`cursor`, and `limit` (default 12, maximum 50). The response contains `items` and `next_cursor`. -Each item is a lightweight result summary. A page reads at most `limit` round pairs after requester -filtering. Rounds without verified public-key state consume a position but produce no item. The -client follows the next cursor even when a page contains no items. - -Archive cursors use append-only round-index positions, not E3 IDs. New rounds do not shift an older -page. Requester matching is case-insensitive. Round-index schema 1 adds requester positions to the -existing JSON index. Startup backfills legacy indexes once and retains concurrent appends. A failed -backfill leaves the legacy version unchanged. Unsupported future versions fail explicitly. Existing -`/state/all` clients remain compatible. Countdown estimates use a shared chain clock and local -display ticks. Contracts still enforce voting deadlines. - ## Chain-Specific BFV Config The protocol release can carry more than one circuit artifact set. Current deployments use this diff --git a/examples/CRISP/client/package.json b/examples/CRISP/client/package.json index c09283b481..95e6518155 100644 --- a/examples/CRISP/client/package.json +++ b/examples/CRISP/client/package.json @@ -9,7 +9,6 @@ }, "homepage": "https://github.com/gnosisguild/CRISP", "scripts": { - "test": "vitest --run --config vitest.config.ts", "cli": "pnpm sh ./scripts/cli.sh", "dev": "vite --no-open --host", "dev-static": "NO_HOT=1 vite --no-open --host", @@ -40,9 +39,6 @@ "wagmi": "^2.14.16" }, "devDependencies": { - "vitest": "1.6.1", - "react-test-renderer": "18.3.1", - "@types/react-test-renderer": "^18.3.0", "@tailwindcss/typography": "^0.5.12", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/examples/CRISP/client/src/components/CircularTiles.tsx b/examples/CRISP/client/src/components/CircularTiles.tsx new file mode 100644 index 0000000000..a316fc5f30 --- /dev/null +++ b/examples/CRISP/client/src/components/CircularTiles.tsx @@ -0,0 +1,32 @@ +// 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 { memo, useState } from 'react' +import CircularTile from './CircularTile' + +const generateRotations = (count: number) => [...Array(count)].map(() => [0, 90, 180, 270][Math.floor(Math.random() * 4)]) + +const CircularTiles = ({ count = 1, className }: { count?: number; className?: string }) => { + const [rotations, setRotations] = useState(() => generateRotations(count)) + const [renderedCount, setRenderedCount] = useState(count) + + // Re-roll the rotations when the number of tiles changes, adjusting state + // during render rather than in an effect. + if (renderedCount !== count) { + setRenderedCount(count) + setRotations(generateRotations(count)) + } + + return ( + <> + {rotations.map((rotation, index) => ( + + ))} + + ) +} + +export default memo(CircularTiles) diff --git a/examples/CRISP/client/src/components/CountdownTime.tsx b/examples/CRISP/client/src/components/CountdownTime.tsx index b4718203e6..4215671061 100644 --- a/examples/CRISP/client/src/components/CountdownTime.tsx +++ b/examples/CRISP/client/src/components/CountdownTime.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react' import { usePublicClient } from 'wagmi' -import { subscribeEstimatedChainTime } from '@/utils/estimated-chain-clock' +import LoadingAnimation from '@/components/LoadingAnimation' interface CountdownTimerProps { endTime: Date @@ -22,29 +22,47 @@ type RemainingTime = { const CountdownTimer: React.FC = ({ endTime }) => { const client = usePublicClient() const [remainingTime, setRemainingTime] = useState(null) - const endTimeMs = endTime.getTime() + const [loading, setLoading] = useState(true) - useEffect( - () => - subscribeEstimatedChainTime(client, (estimatedNowMs) => { - const difference = Math.max(0, endTimeMs - estimatedNowMs) - setRemainingTime({ - days: Math.floor(difference / 86_400_000).toString(), - hours: Math.floor((difference / 3_600_000) % 24).toString(), - minutes: Math.floor((difference / 60_000) % 60).toString(), - seconds: Math.floor((difference / 1_000) % 60).toString(), - }) - }), - [endTimeMs, client], - ) + useEffect(() => { + const timer = setInterval(async () => { + // Use chain block timestamp so countdown matches when poll actually ends (block.timestamp > end_time) + let nowMs: number + if (client) { + try { + const block = await client.getBlock() + nowMs = Number(block.timestamp) * 1000 + } catch { + nowMs = Date.now() + } + } else { + nowMs = Date.now() + } + const difference = endTime.getTime() - nowMs + if (difference <= 0) { + clearInterval(timer) + setLoading(false) + setRemainingTime({ days: '0', hours: '0', minutes: '0', seconds: '0' }) + return + } + + const days = Math.floor(difference / (1000 * 60 * 60 * 24)).toString() + const hours = Math.floor((difference / (1000 * 60 * 60)) % 24).toString() + const minutes = Math.floor((difference / 1000 / 60) % 60).toString() + const seconds = Math.floor((difference / 1000) % 60).toString() + setRemainingTime({ days, hours, minutes, seconds }) + setLoading(false) + }, 1000) + + return () => clearInterval(timer) + }, [endTime, client]) return (
-

- Poll ends in: -

+

Poll ends in:

- {remainingTime && ( + {loading && } + {!loading && remainingTime && (

{remainingTime.days} diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx index 8f3d7069be..85d325ee26 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx @@ -68,7 +68,6 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { getRoundStateLite: getRoundStateLiteRequest, getWebResultByRound, getWebResult, - getArchivePage, getCurrentRound, broadcastVote, getVoteAvailability, @@ -148,17 +147,17 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const currentResult = await getWebResultByRound(currentRound.id) const currentHasTally = !!(currentResult && Array.isArray(currentResult.tally) && currentResult.tally.length > 0) if (!currentHasTally) { - let cursor: string | undefined - do { - const page = await getArchivePage(cursor) - const latestWithTally = page?.items.find((round) => Array.isArray(round.tally) && round.tally.length > 0) - if (latestWithTally) { - if (latestWithTally.round_id !== currentRound.id) fallbackRoundId = latestWithTally.round_id - break - } - if (!page?.next_cursor || page.next_cursor === cursor) break - cursor = page.next_cursor - } while (cursor) + const all = await getWebResult() + const latestWithTally = (all ?? []) + .filter((r) => Array.isArray(r.tally) && r.tally.length > 0) + .sort((a, b) => { + const aId = BigInt(a.round_id) + const bId = BigInt(b.round_id) + return aId === bId ? 0 : aId < bId ? 1 : -1 + })[0] + if (latestWithTally && latestWithTally.round_id !== currentRound.id) { + fallbackRoundId = latestWithTally.round_id + } } } @@ -170,16 +169,13 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } } - const getRoundStateLite = useCallback( - async (roundId: string) => { - const fetchedRoundState = await getRoundStateLiteRequest(roundId) + const getRoundStateLite = async (roundId: string) => { + const fetchedRoundState = await getRoundStateLiteRequest(roundId) - if (fetchedRoundState) { - applyRoundState(fetchedRoundState) - } - }, - [getRoundStateLiteRequest, applyRoundState], - ) + if (fetchedRoundState) { + applyRoundState(fetchedRoundState) + } + } const getRoundStateLiteRequestRef = useRef(getRoundStateLiteRequest) useEffect(() => { @@ -197,7 +193,6 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { let cancelled = false let timer: ReturnType | null = null let delay = ROUND_POLL_INITIAL_MS - let inFlight = false function schedule(wait = delay) { if (cancelled || document.hidden) return @@ -208,52 +203,42 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } async function poll() { - if (cancelled || inFlight) return - inFlight = true - try { - const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + + const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + + if (currentRound) { + const fetched = await getRoundStateLiteRequestRef.current(currentRound.id) if (cancelled) return - if (currentRound) { - const fetched = await getRoundStateLiteRequestRef.current(currentRound.id) - if (cancelled) return - - // The current-round pointer can change while its state is in flight. Confirm it again - // before committing either value, or this effect stops polling on a stale round. - const confirmedRound = await getCurrentRoundRef.current() - if (cancelled) return - if (!confirmedRound || confirmedRound.id !== currentRound.id) { - schedule(1_000) - return - } - - // Fetch the state before storing the round ID. Storing the ID reruns this - // effect and cancels the current request. If we store it first, a round - // that becomes active after page load can discard its successful state - // response and leave the page in the preparing state permanently. - setCurrentRoundId(currentRound.id) - setDisplayedRoundIsFallback(false) - - if (fetched) { - applyRoundState(fetched) - setPendingCurrentRoundId(null) - } else { - setPendingCurrentRoundId(currentRound.id) - } + // The current-round pointer can change while its state is in flight. Confirm it again + // before committing either value, or this effect stops polling on a stale round. + const confirmedRound = await getCurrentRoundRef.current() + if (cancelled) return + if (!confirmedRound || confirmedRound.id !== currentRound.id) { + schedule(1_000) return } - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() - } catch (error) { - if (!cancelled) { - handleGenericError('Round polling failed', error as Error) - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() + // Fetch the state before storing the round ID. Storing the ID reruns this + // effect and cancels the current request. If we store it first, a round + // that becomes active after page load can discard its successful state + // response and leave the page in the preparing state permanently. + setCurrentRoundId(currentRound.id) + setDisplayedRoundIsFallback(false) + + if (fetched) { + applyRoundState(fetched) + setPendingCurrentRoundId(null) + } else { + setPendingCurrentRoundId(currentRound.id) } - } finally { - inFlight = false + return } + + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() } function resumeWhenVisible() { @@ -278,7 +263,6 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { let cancelled = false let timer: ReturnType | null = null let delay = ROUND_POLL_INITIAL_MS - let inFlight = false function schedule() { if (cancelled || document.hidden) return @@ -289,50 +273,40 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { } async function poll() { - if (cancelled || inFlight) return - inFlight = true - try { - const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + + const currentRound = await getCurrentRoundRef.current() + if (cancelled) return + if (currentRound && currentRound.id !== pendingRoundId) { + // A newer round replaced the one whose key we were waiting for. Reset discovery instead + // of keeping the page attached to an old round that may never become readable. + setPendingCurrentRoundId(null) + setCurrentRoundId(null) + return + } + + const fetched = await getRoundStateLiteRequestRef.current(pendingRoundId) + if (cancelled) return + if (fetched) { + const confirmedRound = await getCurrentRoundRef.current() if (cancelled) return - if (currentRound && currentRound.id !== pendingRoundId) { - // A newer round replaced the one whose key we were waiting for. Reset discovery instead - // of keeping the page attached to an old round that may never become readable. - setPendingCurrentRoundId(null) - setCurrentRoundId(null) + if (!confirmedRound) { + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() return } - - const fetched = await getRoundStateLiteRequestRef.current(pendingRoundId) - if (cancelled) return - if (fetched) { - const confirmedRound = await getCurrentRoundRef.current() - if (cancelled) return - if (!confirmedRound) { - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() - return - } - if (confirmedRound.id !== pendingRoundId) { - setPendingCurrentRoundId(null) - setCurrentRoundId(null) - return - } - applyRoundState(fetched) + if (confirmedRound.id !== pendingRoundId) { setPendingCurrentRoundId(null) + setCurrentRoundId(null) return } - - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() - } catch (error) { - if (!cancelled) { - handleGenericError('Round polling failed', error as Error) - delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) - schedule() - } - } finally { - inFlight = false + applyRoundState(fetched) + setPendingCurrentRoundId(null) + return } + + delay = Math.min(delay * 2, ROUND_POLL_MAX_MS) + schedule() } function resumeWhenVisible() { @@ -352,7 +326,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const getPastPolls = async () => { try { - const result = (await getArchivePage())?.items + const result = await getWebResult() if (result) { const convertedPolls = convertPollData(result) setPastPolls(convertedPolls) diff --git a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx index e1c8b1e115..5aa6a836dc 100644 --- a/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx +++ b/examples/CRISP/client/src/hooks/generic/useFetchApi.tsx @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { useCallback, useEffect, useRef, useState } from 'react' +import { useState } from 'react' import axios, { AxiosRequestConfig, Method } from 'axios' import { handleGenericError } from '@/utils/handle-generic-error' @@ -14,34 +14,26 @@ type FetchConfig = AxiosRequestConfig & { export const useApi = () => { const [isLoading, setIsLoading] = useState(false) - const pending = useRef(0) - const mounted = useRef(true) - useEffect(() => { - mounted.current = true - return () => { - mounted.current = false - } - }, []) - const fetchData = useCallback( - async (url: string, method: Method = 'get', data?: U, config?: FetchConfig): Promise => { - pending.current += 1 - if (mounted.current) setIsLoading(true) - const { suppressNotFound = false, ...axiosConfig } = config ?? {} - try { - const response = await axios.request({ ...axiosConfig, url, method, data }) - return response.data - } catch (error) { - if (suppressNotFound && axios.isAxiosError(error) && error.response?.status === 404) return undefined - handleGenericError(`API Error - ${url}`, error as Error) - throw error - } finally { - pending.current -= 1 - if (mounted.current) setIsLoading(pending.current > 0) - } - }, - [], - ) + const fetchData = async ( + url: string, + method: Method = 'get', + data?: U, + config?: FetchConfig, + ): Promise => { + setIsLoading(true) + const { suppressNotFound = false, ...axiosConfig } = config ?? {} + try { + const response = method === 'get' ? await axios.get(`${url}`, axiosConfig) : await axios.post(`${url}`, data, axiosConfig) + return response.data + } catch (error) { + if (suppressNotFound && axios.isAxiosError(error) && error.response?.status === 404) return undefined + handleGenericError(`API Error - ${url}`, error as Error) + } finally { + setIsLoading(false) + } + return undefined + } return { fetchData, isLoading } } diff --git a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts index 4c34eab382..8452baa1bd 100644 --- a/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts +++ b/examples/CRISP/client/src/hooks/interfold/useInterfoldServer.ts @@ -15,10 +15,9 @@ import { VoteStatusResponse, } from '@/model/vote.model' import { useApi } from '../generic/useFetchApi' -import { ArchivePage, PollRequestResult } from '@/model/poll.model' +import { PollRequestResult } from '@/model/poll.model' import { ROUND_REQUESTERS } from '@/utils/constants' import axios from 'axios' -import { useMemo } from 'react' const INTERFOLD_API = import.meta.env.VITE_INTERFOLD_API @@ -29,7 +28,6 @@ const InterfoldEndpoints = { GetRoundStateLite: `${INTERFOLD_API}/state/lite`, GetWebResult: `${INTERFOLD_API}/state/result`, GetWebAllResult: `${INTERFOLD_API}/state/all`, - GetArchivePage: `${INTERFOLD_API}/state/archive`, BroadcastVote: `${INTERFOLD_API}/voting/broadcast`, GetVoteAvailability: `${INTERFOLD_API}/voting/availability`, GetVoteStatus: `${INTERFOLD_API}/voting/status`, @@ -37,70 +35,54 @@ const InterfoldEndpoints = { GetMerkleLeaves: `${INTERFOLD_API}/state/token-holders`, } as const -const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetVoteAvailability, GetRoundStateLite, GetWebResult, GetVoteStatus } = - InterfoldEndpoints - export const useInterfoldServer = () => { + const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetVoteAvailability, GetRoundStateLite, GetWebResult, GetVoteStatus } = + InterfoldEndpoints const { fetchData, isLoading } = useApi() - const endpoints = useMemo(() => { - const getCurrentRound = () => - fetchData( - GetCurrentRound, - 'post', - { requesters: ROUND_REQUESTERS }, - { suppressNotFound: true }, - ) - const getRoundStateLite = (round_id: string) => - fetchData(GetRoundStateLite, 'post', { round_id }, { suppressNotFound: true }) - const getVoteAvailability = async (jobId: string): Promise => { - const url = `${GetVoteAvailability}/${encodeURIComponent(jobId)}` - try { - return (await axios.get(url)).data - } catch (error) { - // A server replacement can legitimately lose its local job database. Tell the caller this - // job is gone so it can clear localStorage and submit again. Other failures are transient. - if (axios.isAxiosError(error) && error.response?.status === 404) return null - handleGenericError(`API Error - ${url}`, error as Error) - return undefined - } - } - const broadcastVote = async ( - vote: BroadcastVoteRequest, - onJobCreated?: (jobId: string) => void, - ): Promise => { - const initial = await fetchData(BroadcastVote, 'post', vote) - if (!initial) return undefined - if (initial.job_id) onJobCreated?.(initial.job_id) - return initial + const getCurrentRound = () => + fetchData(GetCurrentRound, 'post', { requesters: ROUND_REQUESTERS }, { suppressNotFound: true }) + const getRoundStateLite = (round_id: string) => + fetchData(GetRoundStateLite, 'post', { round_id }, { suppressNotFound: true }) + const getVoteAvailability = async (jobId: string): Promise => { + const url = `${GetVoteAvailability}/${encodeURIComponent(jobId)}` + try { + return (await axios.get(url)).data + } catch (error) { + // A server replacement can legitimately lose its local job database. Tell the caller this + // job is gone so it can clear localStorage and submit again. Other failures are transient. + if (axios.isAxiosError(error) && error.response?.status === 404) return null + handleGenericError(`API Error - ${url}`, error as Error) + return undefined } - const getWebResult = () => - fetchData(GetWebAllResult, 'post', { requesters: ROUND_REQUESTERS }) - const getArchivePage = (cursor?: string) => - fetchData(InterfoldEndpoints.GetArchivePage, 'post', { - requesters: ROUND_REQUESTERS, - cursor, - limit: 12, - }) - const getWebResultByRound = (round_id: string) => - fetchData(GetWebResult, 'post', { round_id }, { suppressNotFound: true }) - const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) - const getEligibleVoters = (round_id: string) => - fetchData(InterfoldEndpoints.GetEligibleVoters, 'post', { round_id }) - const getMerkleLeaves = (round_id: string) => - fetchData(InterfoldEndpoints.GetMerkleLeaves, 'post', { round_id }) + } + const broadcastVote = async ( + vote: BroadcastVoteRequest, + onJobCreated?: (jobId: string) => void, + ): Promise => { + const initial = await fetchData(BroadcastVote, 'post', vote) + if (!initial) return undefined + if (initial.job_id) onJobCreated?.(initial.job_id) + return initial + } + const getWebResult = () => + fetchData(GetWebAllResult, 'post', { requesters: ROUND_REQUESTERS }) + const getWebResultByRound = (round_id: string) => fetchData(GetWebResult, 'post', { round_id }) + const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) + const getEligibleVoters = (round_id: string) => + fetchData(InterfoldEndpoints.GetEligibleVoters, 'post', { round_id }) + const getMerkleLeaves = (round_id: string) => + fetchData(InterfoldEndpoints.GetMerkleLeaves, 'post', { round_id }) - return { - getWebResultByRound, - getWebResult, - getArchivePage, - getCurrentRound, - getRoundStateLite, - broadcastVote, - getVoteAvailability, - getVoteStatus, - getEligibleVoters, - getMerkleLeaves, - } - }, [fetchData]) - return { isLoading, ...endpoints } + return { + isLoading, + getWebResultByRound, + getWebResult, + getCurrentRound, + getRoundStateLite, + broadcastVote, + getVoteAvailability, + getVoteStatus, + getEligibleVoters, + getMerkleLeaves, + } } diff --git a/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts b/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts deleted file mode 100644 index 8bbc25ead4..0000000000 --- a/examples/CRISP/client/src/hooks/voting/useArchivePolls.ts +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { useCallback, useEffect, useRef, useState } from 'react' -import type { ArchivePage, PollRequestResult } from '@/model/poll.model' - -type FetchPage = (cursor?: string) => Promise - -export function useArchivePolls(fetchPage: FetchPage) { - const [items, setItems] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const state = useRef({ pending: false, cursor: undefined as string | undefined, done: false }) - - const loadMore = useCallback(async () => { - const current = state.current - if (current.pending || current.done) return - current.pending = true - setIsLoading(true) - setError(null) - try { - const page = await fetchPage(current.cursor) - if (state.current !== current) return - if (!page) throw new Error('Archive response is missing') - if (page.next_cursor !== null && page.next_cursor === current.cursor) throw new Error('Archive cursor did not advance') - setItems((previous) => { - const rows = new Map(previous.map((item) => [item.round_id, item])) - for (const item of page.items) rows.set(item.round_id, item) - return [...rows.values()] - }) - current.cursor = page.next_cursor ?? undefined - current.done = page.next_cursor === null - setHasMore(!current.done) - } catch { - if (state.current === current) setError('Could not load polls. Try again.') - } finally { - current.pending = false - if (state.current === current) setIsLoading(false) - } - }, [fetchPage]) - - useEffect(() => { - state.current = { pending: false, cursor: undefined, done: false } - // Clear the old query result when the data source changes. - // eslint-disable-next-line react-hooks/set-state-in-effect - setItems([]) - setHasMore(true) - void loadMore() - return () => { - state.current = { pending: false, cursor: undefined, done: true } - } - }, [loadMore]) - - return { items, hasMore, isLoading, error, loadMore } -} diff --git a/examples/CRISP/client/src/model/poll.model.ts b/examples/CRISP/client/src/model/poll.model.ts index 549d418b71..e43f5617e2 100644 --- a/examples/CRISP/client/src/model/poll.model.ts +++ b/examples/CRISP/client/src/model/poll.model.ts @@ -28,11 +28,6 @@ export interface PollRequestResult { total_votes: number } -export interface ArchivePage { - items: PollRequestResult[] - next_cursor: string | null -} - export interface Poll { value: number checked: boolean diff --git a/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx b/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx index 1d1bc5c48b..31d7a0603e 100644 --- a/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx +++ b/examples/CRISP/client/src/pages/AllPolls/AllPolls.tsx @@ -4,34 +4,59 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import PollCard from '@/components/Cards/PollCard' import { PollResult } from '@/model/poll.model' import LoadingAnimation from '@/components/LoadingAnimation' import { useVoteManagementContext } from '@/context/voteManagement' import { EditorialShell } from '@/design/Editorial' -import { convertPollData } from '@/utils/methods' -import { useInterfoldServer } from '@/hooks/interfold/useInterfoldServer' -import { useArchivePolls } from '@/hooks/voting/useArchivePolls' +import { debounce } from '@/utils/methods' const AllPolls: React.FC = () => { - const { setPastPolls } = useVoteManagementContext() - const { getArchivePage } = useInterfoldServer() - const { items, hasMore, isLoading, error, loadMore } = useArchivePolls(getArchivePage) - const visiblePolls = useMemo(() => convertPollData(items), [items]) + const { votingRound, pastPolls, getPastPolls, isLoading } = useVoteManagementContext() + const [page, setPage] = useState(0) + const [loadingMore, setLoadingMore] = useState(false) - useEffect(() => { - setPastPolls(visiblePolls) - }, [visiblePolls, setPastPolls]) + const loadMorePolls = useCallback(() => { + if (loadingMore || isLoading) return + setLoadingMore(true) + setTimeout(() => { + setPage((prevPage) => prevPage + 1) + window.scrollTo({ + top: document.documentElement.scrollTop - 150, + behavior: 'smooth', + }) + setLoadingMore(false) + }, 1000) + }, [loadingMore, isLoading]) useEffect(() => { - const handleScroll = () => { - const { scrollTop, clientHeight, scrollHeight } = document.documentElement - if (scrollTop + clientHeight >= scrollHeight - 100 && hasMore && !isLoading && !error) void loadMore() + if (votingRound && votingRound?.pk_bytes) { + const fetchPastPolls = async () => { + await getPastPolls() + } + fetchPastPolls() } - window.addEventListener('scroll', handleScroll, { passive: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [votingRound]) + + const visiblePolls = useMemo(() => pastPolls.slice(0, (page + 1) * 12), [page, pastPolls]) + + const handleScroll = useMemo( + () => + debounce(() => { + const { scrollTop, clientHeight, scrollHeight } = document.documentElement + if (scrollTop + clientHeight >= scrollHeight && !loadingMore && pastPolls.length > visiblePolls.length) { + loadMorePolls() + } + }, 200), + [loadMorePolls, loadingMore, pastPolls.length, visiblePolls.length], + ) + + useEffect(() => { + window.addEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll) - }, [hasMore, isLoading, error, loadMore]) + }, [handleScroll]) return ( @@ -45,7 +70,7 @@ const AllPolls: React.FC = () => {

)} - {!visiblePolls.length && !isLoading && !error && !hasMore &&

There are no polls yet.

} + {!pastPolls.length && !isLoading &&

There are no polls yet.

} {visiblePolls.length > 0 && (
{visiblePolls.map((pollResult: PollResult, index: number) => { @@ -61,11 +86,10 @@ const AllPolls: React.FC = () => { })}
)} - {error &&

{error}

} - {hasMore && !isLoading && ( - + {loadingMore && ( +
+ +
)}
diff --git a/examples/CRISP/client/src/pages/PollResult/PollResult.tsx b/examples/CRISP/client/src/pages/PollResult/PollResult.tsx index 627c95b984..6bae28fd44 100644 --- a/examples/CRISP/client/src/pages/PollResult/PollResult.tsx +++ b/examples/CRISP/client/src/pages/PollResult/PollResult.tsx @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { Fragment, useEffect, useMemo, useState } from 'react' +import React, { Fragment, useEffect, useMemo } from 'react' import CardContent from '@/components/Cards/CardContent' import VotesBadge from '@/components/VotesBadge' import PollCardResult from '@/components/Cards/PollCardResult' @@ -22,7 +22,6 @@ const PollResult: React.FC = () => { const { roundId, type } = params const { pastPolls, getWebResultByRound, pollResult, setPollResult } = useVoteManagementContext() const { roundEndDate, txUrl, roundState } = useVoteManagementContext() - const [error, setError] = useState(null) const activeTotalCount = type === 'confirmation' ? roundState?.vote_count : pollResult?.totalVotes @@ -38,29 +37,21 @@ const PollResult: React.FC = () => { useEffect(() => { if (pollResult || confirmationPoll || !roundId) return - let cancelled = false const fetchPoll = async () => { - setError(null) - try { - const fetched = await getWebResultByRound(roundId) - if (!cancelled && fetched) setPollResult(convertPollData([fetched])[0]) - } catch { - if (!cancelled) setError('Could not load the result. Refresh the page to retry.') + const fetched = await getWebResultByRound(roundId) + if (fetched) { + setPollResult(convertPollData([fetched])[0]) } } - void fetchPoll() - return () => { - cancelled = true - } + fetchPoll() // eslint-disable-next-line react-hooks/exhaustive-deps }, [pastPolls, roundId, confirmationPoll, pollResult]) return (
- {error &&

{error}

} - {loading && !error && ( + {loading && (
diff --git a/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx index 200c6a0b0b..85ca70e2e3 100644 --- a/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx +++ b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx @@ -16,7 +16,6 @@ const RoundPoll: React.FC = () => { const navigate = useNavigate() const { roundState, getRoundStateLite, isLoading, currentRoundId } = useVoteManagementContext() const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const isValidRoundId = roundId !== undefined && /^\d+$/.test(roundId) @@ -29,32 +28,20 @@ const RoundPoll: React.FC = () => { // Load the specific round useEffect(() => { - let cancelled = false const loadRound = async () => { if (isValidRoundId && roundId !== undefined) { setLoading(true) - setError(null) - try { - await getRoundStateLite(roundId) - } catch { - if (!cancelled) setError('Could not load this round. Refresh the page to retry.') - } finally { - if (!cancelled) setLoading(false) - } + await getRoundStateLite(roundId) + setLoading(false) } } - void loadRound() - return () => { - cancelled = true - } + loadRound() }, [isValidRoundId, roundId, getRoundStateLite]) const endTime = useMemo(() => (roundState ? convertTimestampToDate(roundState.end_time) : null), [roundState]) const title = `Round #${roundId}` - if (error) return

{error}

- if (loading || isLoading) { return (
diff --git a/examples/CRISP/client/src/utils/estimated-chain-clock.ts b/examples/CRISP/client/src/utils/estimated-chain-clock.ts deleted file mode 100644 index ab26ebba05..0000000000 --- a/examples/CRISP/client/src/utils/estimated-chain-clock.ts +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only - -export interface BlockClockClient { - getBlock: () => Promise<{ timestamp: bigint }> -} - -type Listener = (estimatedTimeMs: number) => void - -// This clock is for display only. Contracts still enforce the input deadline. -class EstimatedChainClock { - private listeners = new Set() - private observedMs = Date.now() - private observedAt = performance.now() - private tick?: ReturnType - private refresh?: ReturnType - private inFlight = false - - constructor(private client?: BlockClockClient) {} - - private now = () => this.observedMs + (performance.now() - this.observedAt) - private emit = () => this.listeners.forEach((listener) => listener(this.now())) - - private synchronize = async () => { - if (!this.client || this.inFlight || !this.listeners.size) return - this.inFlight = true - try { - const block = await this.client.getBlock() - if (this.listeners.size) { - this.observedMs = Number(block.timestamp) * 1000 - this.observedAt = performance.now() - this.emit() - } - } catch { - // Keep the last estimate when the RPC is unavailable. - } finally { - this.inFlight = false - if (this.listeners.size) this.refresh = setTimeout(this.synchronize, 15_000) - } - } - - subscribe(listener: Listener) { - const first = this.listeners.size === 0 - this.listeners.add(listener) - listener(this.now()) - if (first) { - this.tick = setInterval(this.emit, 1_000) - void this.synchronize() - } - return () => { - this.listeners.delete(listener) - if (!this.listeners.size) { - clearInterval(this.tick) - clearTimeout(this.refresh) - } - } - } -} - -const clocks = new WeakMap() -const localClock = new EstimatedChainClock() - -export function subscribeEstimatedChainTime(client: BlockClockClient | undefined, listener: Listener) { - if (!client) return localClock.subscribe(listener) - let clock = clocks.get(client) - if (!clock) { - clock = new EstimatedChainClock(client) - clocks.set(client, clock) - } - return clock.subscribe(listener) -} diff --git a/examples/CRISP/client/tests/estimated-chain-clock.test.ts b/examples/CRISP/client/tests/estimated-chain-clock.test.ts deleted file mode 100644 index 0f4097b578..0000000000 --- a/examples/CRISP/client/tests/estimated-chain-clock.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { subscribeEstimatedChainTime } from '../src/utils/estimated-chain-clock' - -beforeEach(() => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'performance', 'Date'] })) -afterEach(() => vi.useRealTimers()) - -it('shares block reads while display ticks advance locally', async () => { - const client = { getBlock: vi.fn().mockResolvedValue({ timestamp: 1_000n }) } - const one = vi.fn() - const two = vi.fn() - const stopOne = subscribeEstimatedChainTime(client, one) - const stopTwo = subscribeEstimatedChainTime(client, two) - await vi.advanceTimersByTimeAsync(5_000) - expect(client.getBlock).toHaveBeenCalledTimes(1) - expect(one).toHaveBeenLastCalledWith(1_005_000) - expect(two).toHaveBeenLastCalledWith(1_005_000) - await vi.advanceTimersByTimeAsync(25_000) - expect(client.getBlock).toHaveBeenCalledTimes(3) - stopOne() - stopTwo() - await vi.advanceTimersByTimeAsync(60_000) - expect(client.getBlock).toHaveBeenCalledTimes(3) -}) - -it('does not overlap slow reads, including unsubscribe and resubscribe', async () => { - let resolve!: (block: { timestamp: bigint }) => void - const client = { - getBlock: vi.fn( - () => - new Promise<{ timestamp: bigint }>((done) => { - resolve = done - }), - ), - } - const stop = subscribeEstimatedChainTime(client, vi.fn()) - await vi.advanceTimersByTimeAsync(30_000) - stop() - const stopAgain = subscribeEstimatedChainTime(client, vi.fn()) - expect(client.getBlock).toHaveBeenCalledTimes(1) - resolve({ timestamp: 1n }) - await vi.advanceTimersByTimeAsync(15_000) - expect(client.getBlock).toHaveBeenCalledTimes(2) - stopAgain() - resolve({ timestamp: 2n }) - await vi.advanceTimersByTimeAsync(30_000) - expect(client.getBlock).toHaveBeenCalledTimes(2) -}) - -it('isolates clients and keeps ticking after RPC failure', async () => { - const failedClient = { getBlock: vi.fn().mockRejectedValue(new Error('Offline')) } - const otherClient = { getBlock: vi.fn().mockResolvedValue({ timestamp: 50n }) } - const failedListener = vi.fn() - const otherListener = vi.fn() - const start = Date.now() - const stopFailed = subscribeEstimatedChainTime(failedClient, failedListener) - const stopOther = subscribeEstimatedChainTime(otherClient, otherListener) - await vi.advanceTimersByTimeAsync(5_000) - expect(failedListener).toHaveBeenLastCalledWith(start + 5_000) - expect(otherListener).toHaveBeenLastCalledWith(55_000) - stopFailed() - stopOther() -}) diff --git a/examples/CRISP/client/tests/useArchivePolls.test.ts b/examples/CRISP/client/tests/useArchivePolls.test.ts deleted file mode 100644 index ef39a90747..0000000000 --- a/examples/CRISP/client/tests/useArchivePolls.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { afterEach, expect, it, vi } from 'vitest' -import { createElement, useLayoutEffect } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { useArchivePolls } from '../src/hooks/voting/useArchivePolls' -import type { ArchivePage, PollRequestResult } from '../src/model/poll.model' - -let renderer: ReactTestRenderer | undefined -let archive: ReturnType -function Probe({ fetchPage }: { fetchPage: (cursor?: string) => Promise }) { - const value = useArchivePolls(fetchPage) - useLayoutEffect(() => { - archive = value - }) - return null -} -const row = (round_id: string): PollRequestResult => ({ - round_id, - tally: [1, 0], - option_1_emoji: 'one', - option_2_emoji: 'two', - end_time: 1, - total_votes: 1, -}) -afterEach(() => { - act(() => renderer?.unmount()) - renderer = undefined -}) - -it('shows loaded rows immediately and requests the next cursor only once', async () => { - let resolve!: (page: ArchivePage) => void - const fetchPage = vi - .fn() - .mockResolvedValueOnce({ items: [row('1')], next_cursor: 'v1:2' }) - .mockImplementationOnce( - () => - new Promise((done) => { - resolve = done - }), - ) - await act(async () => { - renderer = create(createElement(Probe, { fetchPage })) - }) - expect(archive.items.map((item) => item.round_id)).toEqual(['1']) - let pending!: Promise - act(() => { - pending = archive.loadMore() - void archive.loadMore() - }) - expect(fetchPage).toHaveBeenCalledTimes(2) - expect(fetchPage).toHaveBeenLastCalledWith('v1:2') - expect(archive.items).toHaveLength(1) - await act(async () => { - resolve({ items: [row('1'), row('340282366920938463463374607431768211456')], next_cursor: null }) - await pending - }) - expect(archive.items).toHaveLength(2) - expect(archive.hasMore).toBe(false) -}) - -it('retains the cursor after a failure and allows a retry', async () => { - const fetchPage = vi - .fn() - .mockResolvedValueOnce({ items: [], next_cursor: 'v1:5' }) - .mockRejectedValueOnce(new Error('Offline')) - .mockResolvedValueOnce({ items: [row('5')], next_cursor: null }) - await act(async () => { - renderer = create(createElement(Probe, { fetchPage })) - }) - await act(async () => { - await archive.loadMore() - }) - expect(archive.error).toContain('Try again') - expect(archive.hasMore).toBe(true) - await act(async () => { - await archive.loadMore() - }) - expect(fetchPage.mock.calls.slice(1)).toEqual([['v1:5'], ['v1:5']]) - expect(archive.items[0].round_id).toBe('5') - expect(archive.error).toBeNull() -}) - -it('discards late results after changing the data source or unmounting', async () => { - let resolve!: (page: ArchivePage) => void - const oldFetch = vi.fn( - () => - new Promise((done) => { - resolve = done - }), - ) - const newFetch = vi.fn().mockResolvedValue({ items: [row('new')], next_cursor: null }) - act(() => { - renderer = create(createElement(Probe, { fetchPage: oldFetch })) - }) - await act(async () => { - renderer!.update(createElement(Probe, { fetchPage: newFetch })) - }) - await act(async () => { - resolve({ items: [row('old')], next_cursor: null }) - await Promise.resolve() - }) - expect(archive.items.map((item) => item.round_id)).toEqual(['new']) -}) diff --git a/examples/CRISP/client/tests/useFetchApi.test.ts b/examples/CRISP/client/tests/useFetchApi.test.ts deleted file mode 100644 index 0128b7dbc1..0000000000 --- a/examples/CRISP/client/tests/useFetchApi.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { createElement, useLayoutEffect } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import axios from 'axios' -import { useApi } from '../src/hooks/generic/useFetchApi' - -vi.mock('axios', () => ({ - default: { request: vi.fn(), isAxiosError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true }, -})) -vi.mock('@/utils/handle-generic-error', () => ({ handleGenericError: vi.fn() })) -let api: ReturnType -let renderer: ReactTestRenderer -function Probe() { - const value = useApi() - useLayoutEffect(() => { - api = value - }) - return null -} -beforeEach(() => { - vi.mocked(axios.request).mockReset() - act(() => { - renderer = create(createElement(Probe)) - }) -}) -afterEach(() => act(() => renderer.unmount())) - -it.each(['get', 'GET', 'post', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const)('dispatches %s without changing it to POST', async (method) => { - vi.mocked(axios.request).mockResolvedValue({ data: { ok: true } }) - let response: unknown - await act(async () => { - response = await api.fetchData('/round', method, { id: 1 }, { timeout: 500, params: { page: 2 } }) - }) - expect(response).toEqual({ ok: true }) - expect(axios.request).toHaveBeenCalledWith({ url: '/round', method, data: { id: 1 }, timeout: 500, params: { page: 2 } }) - expect(api.isLoading).toBe(false) -}) - -it('rejects original errors and suppresses only an explicitly allowed 404', async () => { - const unavailable = { isAxiosError: true, response: { status: 503 } } - vi.mocked(axios.request).mockRejectedValue(unavailable) - await act(async () => { - await expect(api.fetchData('/round', 'get', undefined, { suppressNotFound: true })).rejects.toBe(unavailable) - }) - const missing = { isAxiosError: true, response: { status: 404 } } - vi.mocked(axios.request).mockRejectedValue(missing) - await act(async () => { - await expect(api.fetchData('/round')).rejects.toBe(missing) - }) - await act(async () => { - await expect(api.fetchData('/round', 'get', undefined, { suppressNotFound: true })).resolves.toBeUndefined() - }) - expect(api.isLoading).toBe(false) -}) - -it('stays loading until every concurrent request settles', async () => { - let resolveFirst!: (value: unknown) => void - let resolveSecond!: (value: unknown) => void - vi.mocked(axios.request) - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve - }), - ) - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSecond = resolve - }), - ) - let first!: Promise - let second!: Promise - const fetchData = api.fetchData - act(() => { - first = api.fetchData('/one') - second = api.fetchData('/two') - }) - expect(api.isLoading).toBe(true) - expect(api.fetchData).toBe(fetchData) - await act(async () => { - resolveSecond({ data: 2 }) - await second - }) - expect(api.isLoading).toBe(true) - await act(async () => { - resolveFirst({ data: 1 }) - await first - }) - expect(api.isLoading).toBe(false) -}) diff --git a/examples/CRISP/client/vitest.config.ts b/examples/CRISP/client/vitest.config.ts deleted file mode 100644 index a6eebb5b1a..0000000000 --- a/examples/CRISP/client/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { defineConfig } from 'vitest/config' -import { fileURLToPath } from 'node:url' - -export default defineConfig({ - resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, - test: { include: ['tests/**/*.test.{ts,tsx}'] }, -}) diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 723c8f374d..b97228457b 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -1575,9 +1575,6 @@ pub async fn start_indexer( } } - CurrentRoundRepository::new(crisp_indexer.get_store()) - .ensure_requester_index() - .await?; restore_round_deadline_callbacks(&crisp_indexer).await?; crisp_indexer.listen().await?; info!("CRISP: Indexer listen loop has finished!"); diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index faf6472f7f..69ff185702 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -196,50 +196,6 @@ pub struct WebResultRequest { pub requester: String, } -#[derive(Debug, Deserialize)] -pub struct ArchiveRequest { - #[serde(default)] - pub requesters: Vec, - #[serde(default)] - pub cursor: Option, - #[serde(default = "archive_page_size")] - pub limit: usize, -} - -fn archive_page_size() -> usize { - 12 -} - -impl ArchiveRequest { - pub fn before(&self) -> eyre::Result> { - eyre::ensure!( - (1..=50).contains(&self.limit), - "Archive limit must be between 1 and 50" - ); - self.cursor - .as_ref() - .map(|cursor| { - let position = cursor - .strip_prefix("v1:") - .ok_or_else(|| eyre::eyre!("Invalid archive cursor"))?; - eyre::ensure!( - !position.is_empty() && position.bytes().all(|byte| byte.is_ascii_digit()), - "Invalid archive cursor" - ); - position - .parse::() - .map_err(|_| eyre::eyre!("Invalid archive cursor")) - }) - .transpose() - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ArchivePage { - pub items: Vec, - pub next_cursor: Option, -} - #[derive(Debug, Deserialize, Serialize)] pub struct E3StateLite { pub id: String, diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 3c27a5b5cf..63e50ca09e 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -15,49 +15,10 @@ use eyre::Result; use fhe::bfv::BfvParameters; use log::info; use num_bigint::BigUint; -use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Default, serde::Deserialize, serde::Serialize)] struct RoundIndex { ids: Vec, - #[serde(default)] - schema_version: u8, - #[serde(default)] - requesters: BTreeMap>, -} - -impl RoundIndex { - fn page( - &self, - requesters: &[String], - before: Option, - limit: usize, - ) -> (Vec, Option) { - let end = before.unwrap_or(self.ids.len()).min(self.ids.len()); - let selected: Vec = if requesters.is_empty() { - (0..end).rev().take(limit + 1).collect() - } else { - let positions: BTreeSet = requesters - .iter() - .filter_map(|requester| self.requesters.get(&requester.to_lowercase())) - .flat_map(|positions| positions.range(..end).rev().take(limit + 1).copied()) - .collect(); - positions.into_iter().rev().take(limit + 1).collect() - }; - let next = if selected.len() > limit { - selected - .get(limit - 1) - .map(|position| format!("v1:{position}")) - } else { - None - }; - let ids = selected - .into_iter() - .take(limit) - .map(|position| self.ids[position].clone()) - .collect(); - (ids, next) - } } pub struct CurrentRoundRepository { @@ -80,31 +41,13 @@ impl CurrentRoundRepository { pub async fn record_round(&mut self, e3_id: impl ToString) -> Result<()> { let e3_id = e3_id.to_string(); - let requester = CrispE3Repository::new(self.store.clone(), &e3_id) - .get_crisp() - .await? - .requester - .to_lowercase(); let key = self.round_index_key(); - self.read_round_index().await?; self.store .modify(&key, |index: Option| { - let mut index = index.unwrap_or_else(|| RoundIndex { - schema_version: 1, - ..Default::default() - }); - let position = if let Some(position) = index.ids.iter().position(|id| id == &e3_id) - { - position - } else { + let mut index = index.unwrap_or_default(); + if !index.ids.contains(&e3_id) { index.ids.push(e3_id.clone()); - index.ids.len() - 1 - }; - index - .requesters - .entry(requester.clone()) - .or_default() - .insert(position); + } Some(index) }) .await @@ -112,80 +55,6 @@ impl CurrentRoundRepository { Ok(()) } - async fn read_round_index(&self) -> Result { - let index = self - .store - .get::(&self.round_index_key()) - .await - .map_err(|error| eyre::eyre!("Could not read the round index: {error}"))? - .unwrap_or_else(|| RoundIndex { - schema_version: 1, - ..Default::default() - }); - eyre::ensure!( - index.schema_version <= 1, - "Unsupported round index schema {}. Use a compatible server.", - index.schema_version - ); - Ok(index) - } - - /// Add requester positions to legacy JSON records without changing round order. - pub async fn ensure_requester_index(&self) -> Result<()> { - let index = self.read_round_index().await?; - if index.schema_version == 1 { - return Ok(()); - } - let mut requesters: BTreeMap> = BTreeMap::new(); - for (position, id) in index.ids.iter().enumerate() { - if let Some(round) = CrispE3Repository::new(self.store.clone(), id) - .try_get_crisp() - .await? - { - requesters - .entry(round.requester.to_lowercase()) - .or_default() - .insert(position); - } - } - // Merge under the store lock so concurrent appends are retained. - self.store - .clone() - .modify(&self.round_index_key(), |current: Option| { - current.map(|mut current| { - for (requester, positions) in &requesters { - current - .requesters - .entry(requester.clone()) - .or_default() - .extend(positions); - } - current.schema_version = 1; - current - }) - }) - .await - .map_err(|error| eyre::eyre!("Could not migrate the requester index: {error}"))?; - Ok(()) - } - - pub async fn get_archive_round_ids( - &self, - requesters: &[String], - before: Option, - limit: usize, - ) -> Result<(Vec, Option)> { - eyre::ensure!( - (1..=50).contains(&limit), - "Archive limit must be between 1 and 50" - ); - self.ensure_requester_index().await?; - Ok(self - .read_round_index() - .await? - .page(requesters, before, limit)) - } - pub async fn get_round_ids(&self) -> Result> { let key = self.round_index_key(); let index = self @@ -219,8 +88,15 @@ impl CurrentRoundRepository { &self, requester: String, ) -> Result> { - let (ids, _) = self.get_archive_round_ids(&[requester], None, 1).await?; - Ok(ids.into_iter().next().map(|id| CurrentRound { id })) + for round_id in self.get_round_ids().await?.into_iter().rev() { + let crisp_repo = CrispE3Repository::new(self.store.clone(), &round_id); + + if crisp_repo.is_requested_by(&requester).await? { + return Ok(Some(CurrentRound { id: round_id })); + } + } + + Ok(None) } fn current_round_key(&self) -> String { @@ -295,6 +171,14 @@ impl CrispE3Repository { Ok(self.try_get_crisp().await?.is_some()) } + /// Whether the request-time CRISP record belongs to `requester`. + pub async fn is_requested_by(&self, requester: &str) -> Result { + Ok(self + .try_get_crisp() + .await? + .is_some_and(|round| round.requester.eq_ignore_ascii_case(requester))) + } + /// Whether the generic indexer stored a verified committee public key for this round. pub async fn has_indexed_public_key(&self) -> Result { Ok(self.try_get_e3().await?.is_some()) @@ -637,11 +521,11 @@ impl CrispE3Repository { }; Ok(Some(WebResultRequest { round_id: e3.id, - total_votes: count_active_slots(&e3_crisp.input_slots), tally: e3_crisp.tally, option_1_emoji: e3_crisp.emojis[0].clone(), option_2_emoji: e3_crisp.emojis[1].clone(), end_time: e3.input_window[1], + total_votes: self.get_vote_count().await?, requester: e3_crisp.requester, })) } @@ -914,10 +798,10 @@ pub fn parse_slot_address(address: &str) -> Result<[u8; 20]> { mod tests { use super::{ count_active_slots, parse_slot_address, snapshot_block, CrispE3Repository, - CurrentRoundRepository, RoundIndex, + CurrentRoundRepository, }; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp}; - use e3_sdk::indexer::{DataStore, InMemoryStore, SharedStore}; + use e3_sdk::indexer::{InMemoryStore, SharedStore}; use std::sync::Arc; use tokio::sync::RwLock; @@ -951,132 +835,6 @@ mod tests { } } - #[tokio::test] - async fn archive_migrates_legacy_index_and_retains_cursor_order_after_replay_and_restart() { - let mut store = test_store(); - let legacy: RoundIndex = - serde_json::from_str(include_str!("../../tests/fixtures/round-index-v0.json")).unwrap(); - for (position, id) in legacy.ids.iter().enumerate() { - CrispE3Repository::new(store.clone(), id) - .set_crisp(crisp_round( - if position == 1 { "other" } else { "requester" }, - "Requested", - )) - .await - .unwrap(); - } - store.insert("_e3:round_index", &legacy).await.unwrap(); - let mut current = CurrentRoundRepository::new(store.clone()); - current.ensure_requester_index().await.unwrap(); - let migrated = current.read_round_index().await.unwrap(); - assert_eq!(migrated.schema_version, 1); - assert_eq!(migrated.ids, legacy.ids); - let (first, cursor) = current - .get_archive_round_ids(&["REQUESTER".into()], None, 1) - .await - .unwrap(); - assert_eq!(first, ["3"]); - assert_eq!(cursor.as_deref(), Some("v1:2")); - - CrispE3Repository::new(store.clone(), "4") - .set_crisp(crisp_round("requester", "Requested")) - .await - .unwrap(); - current.record_round("4").await.unwrap(); - current.record_round("3").await.unwrap(); - let restarted = CurrentRoundRepository::new(store.clone()); - let (second, next) = restarted - .get_archive_round_ids(&["requester".into()], Some(2), 1) - .await - .unwrap(); - assert_eq!(second, ["1"]); - assert_eq!(next, None); - let (all, _) = restarted - .get_archive_round_ids(&[], None, 50) - .await - .unwrap(); - assert_eq!( - all, - ["4", "3", "340282366920938463463374607431768211456", "1"] - ); - - // A page reads the index, not the historical CRISP records. - for id in &all { - store - .insert(&format!("_e3:crisp:{id}"), &"invalid round record") - .await - .unwrap(); - } - assert_eq!( - restarted - .get_archive_round_ids(&["requester".into()], None, 2) - .await - .unwrap() - .0, - ["4", "3"] - ); - } - - #[tokio::test] - async fn archive_migration_is_idempotent_and_does_not_stamp_a_failed_backfill() { - let mut store = test_store(); - let legacy: RoundIndex = - serde_json::from_str(include_str!("../../tests/fixtures/round-index-v0.json")).unwrap(); - store.insert("_e3:round_index", &legacy).await.unwrap(); - store - .insert("_e3:crisp:1", &"invalid round record") - .await - .unwrap(); - let current = CurrentRoundRepository::new(store.clone()); - assert!(current.ensure_requester_index().await.is_err()); - assert_eq!(current.read_round_index().await.unwrap().schema_version, 0); - CrispE3Repository::new(store.clone(), "1") - .set_crisp(crisp_round("requester", "Requested")) - .await - .unwrap(); - current.ensure_requester_index().await.unwrap(); - current.ensure_requester_index().await.unwrap(); - assert_eq!( - current - .get_archive_round_ids(&["requester".into(), "REQUESTER".into()], None, 12) - .await - .unwrap() - .0, - ["1"] - ); - - let unsupported = RoundIndex { - schema_version: 2, - ..Default::default() - }; - store.insert("_e3:round_index", &unsupported).await.unwrap(); - assert!(current - .ensure_requester_index() - .await - .unwrap_err() - .to_string() - .contains("Unsupported round index schema")); - } - - #[test] - fn archive_request_rejects_invalid_limits_and_cursors() { - use crate::server::models::ArchiveRequest; - let valid: ArchiveRequest = - serde_json::from_str(r#"{"cursor":"v1:123","requesters":[]}"#).unwrap(); - assert_eq!(valid.before().unwrap(), Some(123)); - assert_eq!(valid.limit, 12); - for input in [ - r#"{"limit":0}"#, - r#"{"limit":51}"#, - r#"{"cursor":"v2:3"}"#, - r#"{"cursor":"v1:-1"}"#, - r#"{"cursor":"v1:184467440737095516160"}"#, - ] { - let request: ArchiveRequest = serde_json::from_str(input).unwrap(); - assert!(request.before().is_err(), "{input}"); - } - } - #[test] fn counts_each_slot_once_no_matter_how_long_its_chain_is() { let slot_a = [1u8; 20]; diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs index ddfca940a1..366f9df6be 100644 --- a/examples/CRISP/server/src/server/routes/state.rs +++ b/examples/CRISP/server/src/server/routes/state.rs @@ -10,9 +10,8 @@ use crate::server::{ app_data::AppData, data_availability::AvailabilityService, models::{ - canonical_e3_id, e3_id_to_u256, ArchivePage, ArchiveRequest, GetRoundRequest, JsonResponse, - PreviousCiphertextRequest, PreviousCiphertextResponse, RoundRequestWithRequester, - WebhookPayload, + canonical_e3_id, e3_id_to_u256, GetRoundRequest, JsonResponse, PreviousCiphertextRequest, + PreviousCiphertextResponse, RoundRequestWithRequester, WebhookPayload, }, rate_limit::ChainRateLimiter, }; @@ -30,7 +29,6 @@ pub fn setup_routes(config: &mut web::ServiceConfig) { web::scope("/state") .route("/result", web::post().to(get_round_result)) .route("/all", web::post().to(get_all_round_results)) - .route("/archive", web::post().to(get_archive_page)) .route("/lite", web::post().to(get_round_state_lite)) // The handler verifies the compute proof on Ethereum before it creates an Avail job. // Valid retries are idempotent, so this endpoint needs no separate caller identity. @@ -290,40 +288,6 @@ async fn get_all_round_results( HttpResponse::Ok().json(states) } -async fn get_archive_page( - data: web::Json, - store: web::Data, -) -> impl Responder { - let before = match data.before() { - Ok(before) => before, - Err(error) => return HttpResponse::BadRequest().body(error.to_string()), - }; - let (ids, next_cursor) = match store - .current_round() - .get_archive_round_ids(&data.requesters, before, data.limit) - .await - { - Ok(page) => page, - Err(error) => { - error!("Could not read the archive index: {error}"); - return HttpResponse::InternalServerError().body("Could not read the archive index"); - } - }; - let mut items = Vec::with_capacity(ids.len()); - for id in ids { - match store.e3(id).try_get_web_result_request().await { - Ok(Some(summary)) => items.push(summary), - Ok(None) => {} - Err(error) => { - error!("Could not read an archive summary: {error}"); - return HttpResponse::InternalServerError() - .body("Could not read an archive summary"); - } - } - } - HttpResponse::Ok().json(ArchivePage { items, next_cursor }) -} - /// Get the state for a given round /// /// # Arguments @@ -404,154 +368,3 @@ async fn handle_get_eligible_addresses( } } } - -#[cfg(test)] -mod archive_tests { - use super::setup_routes; - use crate::server::{app_data::AppData, database::SledDB}; - use actix_web::{http::StatusCode, test, web, App}; - use e3_sdk::{ - evm_helpers::contracts::CommitteeSize, - indexer::{models::E3, DataStore, SharedStore}, - }; - use serde_json::{json, Value}; - use std::sync::Arc; - use tokio::sync::RwLock; - - const FULL_WIDTH_ID: &str = "340282366920938463463374607431768211456"; - - async fn fixture() -> (web::Data, SharedStore) { - let db = SledDB { - db: sled::Config::new().temporary(true).open().unwrap(), - }; - let mut store = SharedStore::new(Arc::new(RwLock::new(db))); - store - .insert( - "_e3:round_index", - &json!({ - "ids": [FULL_WIDTH_ID, "1", "2"], "schema_version": 1, - "requesters": {"requester": [0, 2], "other": [1]} - }), - ) - .await - .unwrap(); - let crisp = json!({ - "emojis": ["one", "two"], "start_time": 0, "end_time": 100, - "status": "Finished", "tally": ["7", "3"], "token_holder_hashes": [], - "eligible_addresses": [], "token_address": "token", "balance_threshold": "1", - "ciphertext_inputs": [], "requester": "requester", "num_options": "2", - "credit_mode": 0, "credits": "1" - }); - for id in [FULL_WIDTH_ID, "2"] { - store - .insert(&format!("_e3:crisp:{id}"), &crisp) - .await - .unwrap(); - } - store - .insert("_e3:1", &"unselected invalid record") - .await - .unwrap(); - let e3 = E3 { - chain_id: 1, - id: FULL_WIDTH_ID.into(), - input_window: [0, 100], - ciphertext_inputs: vec![], - ciphertext_output: vec![], - ciphertext_output_reference: None, - ciphertext_commitment: vec![], - committee_public_key: vec![1], - committee_public_key_hash: vec![], - e3_params: vec![], - custom_params: vec![], - interfold_address: "contract".into(), - encryption_scheme_id: vec![], - crypto_config_id: vec![], - plaintext_output: vec![], - request_block: 1, - seed: [0; 32], - committee_size: CommitteeSize::Minimum, - requester: "requester".into(), - }; - store - .insert(&format!("_e3:{FULL_WIDTH_ID}"), &e3) - .await - .unwrap(); - (web::Data::new(AppData::new(store.clone())), store) - } - - #[actix_web::test] - async fn archive_http_pages_pending_rounds_and_returns_full_width_summary_ids() { - let (data, _) = fixture().await; - let app = test::init_service(App::new().app_data(data).configure(setup_routes)).await; - let first: Value = test::call_and_read_body_json( - &app, - test::TestRequest::post() - .uri("/state/archive") - .set_json(json!({"requesters": ["REQUESTER"], "limit": 1})) - .to_request(), - ) - .await; - assert_eq!(first, json!({"items": [], "next_cursor": "v1:2"})); - let second: Value = test::call_and_read_body_json( - &app, - test::TestRequest::post() - .uri("/state/archive") - .set_json(json!({ - "requesters": ["requester"], "limit": 1, "cursor": first["next_cursor"] - })) - .to_request(), - ) - .await; - assert_eq!( - second, - json!({"items": [{ - "round_id": FULL_WIDTH_ID, "tally": ["7", "3"], "option_1_emoji": "one", - "option_2_emoji": "two", "total_votes": 0, "end_time": 100, "requester": "requester" - }], "next_cursor": null}) - ); - } - - #[actix_web::test] - async fn archive_http_reports_bad_requests_and_store_failures() { - let (data, mut store) = fixture().await; - let app = test::init_service(App::new().app_data(data).configure(setup_routes)).await; - for body in [ - json!({"limit": 0}), - json!({"limit": 51}), - json!({"cursor": "v2:1"}), - ] { - let response = test::call_service( - &app, - test::TestRequest::post() - .uri("/state/archive") - .set_json(body) - .to_request(), - ) - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - let response = test::call_service( - &app, - test::TestRequest::post() - .uri("/state/archive") - .set_json(json!({"requesters": ["other"]})) - .to_request(), - ) - .await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - store - .insert("_e3:round_index", &"invalid index") - .await - .unwrap(); - let response = test::call_service( - &app, - test::TestRequest::post() - .uri("/state/archive") - .set_json(json!({})) - .to_request(), - ) - .await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } -} diff --git a/examples/CRISP/server/tests/fixtures/round-index-v0.json b/examples/CRISP/server/tests/fixtures/round-index-v0.json deleted file mode 100644 index 6a0e81b9fb..0000000000 --- a/examples/CRISP/server/tests/fixtures/round-index-v0.json +++ /dev/null @@ -1 +0,0 @@ -{"ids":["1","340282366920938463463374607431768211456","3"]} diff --git a/package.json b/package.json index 5d702b0a1e..f29b1b7591 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,7 @@ "provenance:manifest": "tsx scripts/generate-provenance-manifest.ts", "check:verifiers": "tsx scripts/generate-verifiers.ts --circuits dkg_aggregator,decryption_aggregator --check", "test:circuit-tooling": "tsx --test scripts/circuit-artifacts.test.ts", - "test": "pnpm evm:test && pnpm rust:test && pnpm rust:test:slashing && pnpm rust:test:proofs && pnpm sdk:test && pnpm sdk:test:proofs:prepared && pnpm test:web && pnpm noir:test", - "test:web": "pnpm react:test && pnpm dashboard:test && pnpm crisp:test:client", + "test": "pnpm evm:test && pnpm rust:test && pnpm rust:test:slashing && pnpm rust:test:proofs && pnpm sdk:test && pnpm sdk:test:proofs:prepared && pnpm noir:test", "test:integration": "cd ./tests/integration && ./test.sh", "coverage": "pnpm evm:coverage", "prepare": "husky", @@ -71,9 +70,6 @@ "mcp:build": "cd packages/interfold-mcp && pnpm build", "mcp:release": "cd packages/interfold-mcp && pnpm release", "react:build": "cd packages/interfold-react && pnpm build", - "react:test": "pnpm -C packages/interfold-react test", - "dashboard:test": "pnpm -C packages/interfold-dashboard test", - "crisp:test:client": "pnpm -C examples/CRISP/client test", "sdk:build": "cd packages/interfold-sdk && pnpm build", "sdk:test": "cd packages/interfold-sdk && pnpm test", "sdk:test:proofs": "cd packages/interfold-sdk && pnpm test:proofs", diff --git a/packages/interfold-dashboard/package.json b/packages/interfold-dashboard/package.json index 69b54c1d83..4a7b6dcea0 100644 --- a/packages/interfold-dashboard/package.json +++ b/packages/interfold-dashboard/package.json @@ -6,7 +6,6 @@ "description": "Interfold / CRISP public observation dashboard", "license": "LGPL-3.0-only", "scripts": { - "test": "vitest --run", "dev": "vite", "build": "vite build", "preview": "vite preview", @@ -19,7 +18,6 @@ "viem": "^2.21.0" }, "devDependencies": { - "vitest": "1.6.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/packages/interfold-dashboard/src/lib/e3.ts b/packages/interfold-dashboard/src/lib/e3.ts index 1a6a946268..8d1eb2d657 100644 --- a/packages/interfold-dashboard/src/lib/e3.ts +++ b/packages/interfold-dashboard/src/lib/e3.ts @@ -5,7 +5,6 @@ // or FITNESS FOR A PARTICULAR PURPOSE. // On-chain E3 fetchers — read events + view functions and assemble dashboard records. -import { CanonicalEventHistory, type HistorySnapshot, type IndexedLog } from './event-history' import { CONTRACTS, DEPLOY_BLOCK, E3Stage, TIMEOUTS, ciphernodeRegistryAbi, interfoldAbi, publicClient } from './chain' // Helper: pull a single named event ABI item out of the typechain bundle. @@ -62,15 +61,8 @@ const CRISP_GET_ROUND_DATA = { ], } as const -const history = new CanonicalEventHistory( - { - getBlock: (args) => publicClient.getBlock(args), - getLogs: (args) => publicClient.getLogs(args as any), - }, - `${publicClient.chain?.id}:${DEPLOY_BLOCK}:${CONTRACTS.Interfold}:${CONTRACTS.CiphernodeRegistry}:${CONTRACTS.CRISPProgram}`, -) - -const isTerminalStage = (stage: number) => stage === E3Stage.Complete || stage === E3Stage.Failed +// Public RPCs cap getLogs range. 9_500 keeps us safely under common 10k limits. +const LOG_CHUNK = 9_500n // An E3 is a CRISP poll only if its program contract is the CRISPProgram. // Other E3s on the same Interfold deployment run different programs and must not @@ -171,8 +163,8 @@ export type E3FullDetails = E3Summary & { // Aggregated inputs. inputsTracked is true only for programs whose input // event we understand (CRISP); for other programs inputs aren't observable // from the dashboard, so ballotCount is 0 and inputsTracked is false. - // Each accepted input, including a re-vote, has a distinct tree index. - // Replayed copies of the same event count once. + // ballotCount is the number of DISTINCT ballots (re-votes are not counted + // twice). ballotEvents holds the raw on-chain events (incl. re-votes). inputsTracked: boolean ballotCount: number ballotEvents: Array<{ @@ -193,17 +185,13 @@ export type E3FullDetails = E3Summary & { } // Resolve unix timestamps for a (small, bounded) set of block numbers, deduped. -async function blockTimestamps(snapshot: HistorySnapshot, blocks: bigint[]): Promise> { +async function blockTimestamps(blocks: bigint[]): Promise> { const uniq = Array.from(new Set(blocks.filter((b) => b > 0n).map((b) => b.toString()))) const entries = await Promise.all( uniq.map(async (s) => { try { - const cached = snapshot.get(`timestamp:${s}`) - if (cached !== undefined) return [s, cached] as const const b = await publicClient.getBlock({ blockNumber: BigInt(s) }) - const timestamp = Number(b.timestamp) - snapshot.set(`timestamp:${s}`, timestamp) - return [s, timestamp] as const + return [s, Number(b.timestamp)] as const } catch { return [s, 0] as const } @@ -212,14 +200,18 @@ async function blockTimestamps(snapshot: HistorySnapshot, blocks: bigint[]): Pro return new Map(entries) } -async function getLogsChunked( - snapshot: HistorySnapshot, +async function getLogsChunked( args: Omit[0], 'fromBlock' | 'toBlock'>, from: bigint, to: bigint, ): Promise { - if (snapshot.head !== to) throw new Error('The event range does not match the snapshot.') - return snapshot.logs(args, from) + const out: any[] = [] + for (let start = from; start <= to; start += LOG_CHUNK + 1n) { + const end = start + LOG_CHUNK > to ? to : start + LOG_CHUNK + const logs = await publicClient.getLogs({ ...args, fromBlock: start, toBlock: end } as any) + out.push(...logs) + } + return out as T[] } export async function fetchLatestBlock(): Promise { @@ -233,10 +225,8 @@ const BLOCKS_PER_DAY = 7200n export async function fetchRecentBallotCount(): Promise { const head = await fetchLatestBlock() const from = head > BLOCKS_PER_DAY + DEPLOY_BLOCK ? head - BLOCKS_PER_DAY : DEPLOY_BLOCK - return history.read(head, async (snapshot) => { - const logs = await getLogsChunked(snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, from, head) - return logs.length - }) + const logs = await getLogsChunked({ address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, from, head) + return logs.length } export type FetchE3Opts = { @@ -248,13 +238,7 @@ export type FetchE3Opts = { export async function fetchE3List(opts: FetchE3Opts = {}): Promise { const { crispOnly = false, toBlock } = opts const head = toBlock ?? (await fetchLatestBlock()) - return history.read(head, (snapshot) => fetchE3ListSnapshot(snapshot, crispOnly)) -} - -async function fetchE3ListSnapshot(snapshot: HistorySnapshot, crispOnly: boolean): Promise { - const head = snapshot.head const logs = await getLogsChunked( - snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_REQUESTED, @@ -265,38 +249,26 @@ async function fetchE3ListSnapshot(snapshot: HistorySnapshot, crispOnly: boolean const scoped = crispOnly ? logs.filter((log) => isCrispE3(log.args.e3.e3Program)) : logs - const active = scoped.filter((log) => snapshot.get(`stage:${log.args.e3Id}`) === undefined) - const [stageResults, ballotCounts] = await Promise.all([ + const [stages, ballotCounts] = await Promise.all([ // Current stage of each E3 in one multicall — lets the list show real status // (completed / failed / expired) rather than guessing. - active.length - ? (publicClient.multicall as any)({ - blockNumber: head, - contracts: active.map((log) => ({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3Stage', - args: [log.args.e3Id], - })), - allowFailure: true, - }) - : Promise.resolve([]), + (publicClient.multicall as any)({ + contracts: scoped.map((log) => ({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3Stage', + args: [log.args.e3Id], + })), + allowFailure: true, + }), // CRISP view: one scan of all ballots, grouped per E3 (distinct voteIndex), // so every history row shows its real count without a per-poll fetch. - crispOnly ? fetchCrispBallotCounts(snapshot) : Promise.resolve(new Map()), + crispOnly ? fetchCrispBallotCounts(head) : Promise.resolve(new Map()), ]) - const stages = new Map() - active.forEach((log, index) => { - const result = stageResults[index] - const stage = result.status === 'success' ? Number(result.result) : E3Stage.None - stages.set(log.args.e3Id.toString(), stage) - if (isTerminalStage(stage)) snapshot.set(`stage:${log.args.e3Id}`, stage) - }) - - const out: E3Summary[] = scoped.map((log) => { + const out: E3Summary[] = scoped.map((log, i) => { const { e3Id, e3 } = log.args - const stage = snapshot.get(`stage:${e3Id}`) ?? stages.get(e3Id.toString()) ?? E3Stage.None + const stageResult = stages[i] return { id: e3Id, e3Program: e3.e3Program, @@ -305,7 +277,7 @@ async function fetchE3ListSnapshot(snapshot: HistorySnapshot, crispOnly: boolean requestTxHash: log.transactionHash, inputWindow: [e3.inputWindow[0], e3.inputWindow[1]] as [bigint, bigint], committeeSize: Number(e3.committeeSize), - stage, + stage: stageResult.status === 'success' ? Number(stageResult.result) : E3Stage.None, ballotCount: ballotCounts.get(e3Id.toString()) ?? 0, } }) @@ -316,10 +288,9 @@ async function fetchE3ListSnapshot(snapshot: HistorySnapshot, crispOnly: boolean } // Distinct ballot count per CRISP E3, from a single scan of all InputPublished -// events grouped by e3Id. Each accepted re-vote has its own index. -async function fetchCrispBallotCounts(snapshot: HistorySnapshot): Promise> { - const head = snapshot.head - const inputs = await getLogsChunked(snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, DEPLOY_BLOCK, head) +// events grouped by e3Id (re-votes reuse a voteIndex, so we count unique ones). +async function fetchCrispBallotCounts(head: bigint): Promise> { + const inputs = await getLogsChunked({ address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED }, DEPLOY_BLOCK, head) const byE3 = new Map>() for (const l of inputs) { const id = l.args.e3Id.toString() @@ -332,59 +303,42 @@ async function fetchCrispBallotCounts(snapshot: HistorySnapshot): Promise { const head = toBlock ?? (await fetchLatestBlock()) - return history.read(head, (snapshot) => fetchE3DetailsSnapshot(e3Id, snapshot)) -} - -async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): Promise { - const head = snapshot.head // 1. Pull live E3 struct + stage + currently-escrowed fee. const [e3, stage, feeEscrowed] = await Promise.all([ - snapshot.get(`complete:${e3Id}`) ?? - ((publicClient.readContract as any)({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3', - args: [e3Id], - blockNumber: head, - }) as Promise), - snapshot.get(`stage:${e3Id}`) ?? - ((publicClient.readContract as any)({ - address: CONTRACTS.Interfold, - abi: interfoldAbi, - functionName: 'getE3Stage', - args: [e3Id], - blockNumber: head, - }) as Promise), + (publicClient.readContract as any)({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3', + args: [e3Id], + }) as Promise, + (publicClient.readContract as any)({ + address: CONTRACTS.Interfold, + abi: interfoldAbi, + functionName: 'getE3Stage', + args: [e3Id], + }) as Promise, (publicClient.readContract as any)({ address: CONTRACTS.Interfold, abi: interfoldAbi, functionName: 'e3Payments', args: [e3Id], - blockNumber: head, }).catch(() => 0n) as Promise, ]) - if (isTerminalStage(stage)) snapshot.set(`stage:${e3Id}`, stage) - if (stage === E3Stage.Complete) snapshot.set(`complete:${e3Id}`, e3) - // CRISP round configuration. Only CRISP E3s expose it, and an uninitialised round // reports 0 options — in both cases the tally stays undecodable rather than guessed. const numOptions = isCrispE3(e3.e3Program) - ? (snapshot.get(`options:${e3Id}`) ?? - (await ((publicClient.readContract as any)({ + ? await ((publicClient.readContract as any)({ address: CONTRACTS.CRISPProgram, abi: [CRISP_GET_ROUND_DATA], functionName: 'getRoundData', args: [e3Id], - blockNumber: head, }) .then((data: readonly unknown[]) => Number(data[2] as bigint) || undefined) - .catch(() => undefined) as Promise))) + .catch(() => undefined) as Promise) : undefined - if (numOptions !== undefined) snapshot.set(`options:${e3Id}`, numOptions) - // `e3.requestBlock` is misnamed: on this contract version it stores // `block.timestamp` (EIP-6372 timestamp clock), not a block number. Using it // as fromBlock would push the scan range past chain head and silently miss @@ -393,7 +347,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): // 2. Find the E3Requested tx for this id (for the inspector header). const requestLogs = await getLogsChunked( - snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_REQUESTED, @@ -412,7 +365,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): // transition (the registry's CommitteePublished event has drifted from our ABI). const [requestedEvents, finalizedEvents, stageChanges] = await Promise.all([ getLogsChunked( - snapshot, { address: CONTRACTS.CiphernodeRegistry, event: REGISTRY_COMMITTEE_REQUESTED, @@ -422,7 +374,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): head, ), getLogsChunked( - snapshot, { address: CONTRACTS.CiphernodeRegistry, event: REGISTRY_COMMITTEE_FINALIZED, @@ -432,7 +383,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): head, ), getLogsChunked( - snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_E3_STAGE_CHANGED, @@ -458,7 +408,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): const [inputs, results, rewards] = await Promise.all([ inputsTracked ? getLogsChunked( - snapshot, { address: CONTRACTS.CRISPProgram, event: CRISP_INPUT_PUBLISHED, @@ -469,7 +418,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): ) : Promise.resolve([] as any[]), getLogsChunked( - snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_PLAINTEXT_PUBLISHED, @@ -479,7 +427,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): head, ), getLogsChunked( - snapshot, { address: CONTRACTS.Interfold, event: INTERFOLD_REWARDS_DISTRIBUTED, @@ -489,7 +436,7 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): head, ), ]) - // Count each accepted input index once, including re-votes. + // Distinct ballots: re-votes reuse the same Merkle-leaf index, so dedupe. const ballotCount = inputsTracked ? new Set(inputs.map((l: any) => l.args.index.toString())).size : 0 // Real committee reward total (sum of per-node amounts), once distributed. const committeeReward = rewards.length @@ -502,7 +449,6 @@ async function fetchE3DetailsSnapshot(e3Id: bigint, snapshot: HistorySnapshot): const shownBallots = inputs.slice(0, 6) if (inputs.length > 6) shownBallots.push(inputs[inputs.length - 1]) const ts = await blockTimestamps( - snapshot, [finLog?.blockNumber, pubLog?.blockNumber, resultLog?.blockNumber, ...shownBallots.map((l: any) => l.blockNumber)].filter( (b): b is bigint => typeof b === 'bigint', ), diff --git a/packages/interfold-dashboard/src/lib/event-history.ts b/packages/interfold-dashboard/src/lib/event-history.ts deleted file mode 100644 index 1d8f77ff23..0000000000 --- a/packages/interfold-dashboard/src/lib/event-history.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only - -export type IndexedLog = { - blockNumber: bigint | null - blockHash: string | null - transactionHash: string | null - logIndex: number | null - removed?: boolean -} - -type HistoryClient = { - getBlock: (args: { blockNumber: bigint }) => Promise<{ hash: string | null }> - getLogs: (args: Record) => Promise -} -type Stream = { from: bigint; to: bigint; logs: IndexedLog[] } -const LOG_CHUNK = 9_500n - -function queryKey(value: unknown): string { - return JSON.stringify(value, (_, item) => { - if (typeof item === 'bigint') return { bigint: item.toString() } - if (item && typeof item === 'object' && !Array.isArray(item)) { - return Object.fromEntries( - Object.keys(item) - .sort() - .map((key) => [key, item[key]]), - ) - } - return item - }) -} - -export class HistorySnapshot { - constructor( - readonly head: bigint, - private client: HistoryClient, - private streams: Map, - private values: Map, - private checkCancelled: () => void, - ) {} - - get(key: string): T | undefined { - return this.values.get(key) as T | undefined - } - set(key: string, value: T) { - this.values.set(key, value) - } - - async logs(args: Record, from: bigint): Promise { - this.checkCancelled() - if (from > this.head) return [] - const key = queryKey(args) - const cached = this.streams.get(key) - let logs = cached?.logs ?? [] - const ranges: Array<[bigint, bigint]> = cached - ? [ - ...(from < cached.from ? [[from, cached.from - 1n] as [bigint, bigint]] : []), - ...(this.head > cached.to ? [[cached.to + 1n, this.head] as [bigint, bigint]] : []), - ] - : [[from, this.head]] - for (const [start, end] of ranges) { - const additions: IndexedLog[] = [] - for (let block = start; block <= end; block += LOG_CHUNK + 1n) { - this.checkCancelled() - const toBlock = block + LOG_CHUNK < end ? block + LOG_CHUNK : end - const result = await this.client.getLogs({ ...args, fromBlock: block, toBlock }) - for (const log of result) { - if (log.removed || log.blockNumber === null || !log.blockHash || !log.transactionHash || log.logIndex === null) { - throw new Error('The RPC returned an unconfirmed log. Retry the refresh.') - } - if (log.blockNumber >= block && log.blockNumber <= toBlock) additions.push(log) - } - } - logs = logs.concat(additions) - } - if (ranges.length) { - const unique = new Map(logs.map((log) => [`${log.blockHash}:${log.transactionHash}:${log.logIndex}`, log])) - logs = [...unique.values()].sort((a, b) => { - if (a.blockNumber !== b.blockNumber) return a.blockNumber! < b.blockNumber! ? -1 : 1 - return a.logIndex! - b.logIndex! - }) - this.streams.set(key, { from: cached && cached.from < from ? cached.from : from, to: this.head, logs }) - } - return logs.filter((log) => log.blockNumber! >= from && log.blockNumber! <= this.head) as T[] - } -} - -// One instance belongs to one client and deployment. Failed reads commit no cursors or values. -export class CanonicalEventHistory { - private streams = new Map() - private values = new Map() - private anchor?: { number: bigint; hash: string } - private queue: Promise = Promise.resolve() - private epoch = 0 - - constructor( - private client: HistoryClient, - readonly scope: string, - ) {} - - reset() { - this.epoch += 1 - this.streams.clear() - this.values.clear() - this.anchor = undefined - } - - read(head: bigint, work: (snapshot: HistorySnapshot) => Promise, signal?: AbortSignal): Promise { - const epoch = this.epoch - const run = async () => { - const checkCancelled = () => { - if (signal?.aborted || epoch !== this.epoch) throw new Error('The history refresh was cancelled.') - } - checkCancelled() - const block = await this.client.getBlock({ blockNumber: head }) - if (!block.hash) throw new Error('The requested block has no hash.') - let reset = this.anchor !== undefined && head < this.anchor.number - if (this.anchor && !reset) { - const oldHash = head === this.anchor.number ? block.hash : (await this.client.getBlock({ blockNumber: this.anchor.number })).hash - reset = oldHash !== this.anchor.hash - } - const streams = reset ? new Map() : new Map(this.streams) - const values = reset ? new Map() : new Map(this.values) - const snapshot = new HistorySnapshot(head, this.client, streams, values, checkCancelled) - const result = await work(snapshot) - checkCancelled() - const after = await this.client.getBlock({ blockNumber: head }) - if (after.hash !== block.hash) throw new Error('The chain changed during the refresh. Retry the refresh.') - checkCancelled() - this.streams = streams - this.values = values - this.anchor = { number: head, hash: block.hash } - return result - } - const result = this.queue.then(run, run) - this.queue = result.then( - () => undefined, - () => undefined, - ) - return result - } -} diff --git a/packages/interfold-dashboard/tests/e3-cache.test.ts b/packages/interfold-dashboard/tests/e3-cache.test.ts deleted file mode 100644 index df8a89461e..0000000000 --- a/packages/interfold-dashboard/tests/e3-cache.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { beforeEach, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getBlock: vi.fn(), - getLogs: vi.fn(), - multicall: vi.fn(), - readContract: vi.fn(), -})) -vi.mock('../src/lib/chain', () => ({ - CONTRACTS: { Interfold: '0x1', CiphernodeRegistry: '0x2', CRISPProgram: '0x3' }, - DEPLOY_BLOCK: 1n, - E3Stage: { None: 0, Requested: 1, CommitteeFinalized: 2, KeyPublished: 3, CiphertextReady: 4, Complete: 5, Failed: 6 }, - TIMEOUTS: { computeWindow: 10, decryptionWindow: 10 }, - interfoldAbi: ['E3Requested', 'PlaintextOutputPublished', 'RewardsDistributed', 'E3StageChanged'].map((name) => ({ - type: 'event', - name, - })), - ciphernodeRegistryAbi: ['CommitteeRequested', 'SortitionCommitteeFinalized'].map((name) => ({ type: 'event', name })), - publicClient: { ...mocks, chain: { id: 1 } }, -})) -const round = (id: bigint) => ({ - blockNumber: id, - blockHash: `hash:${id}`, - transactionHash: `tx:${id}`, - logIndex: 0, - args: { - e3Id: id, - e3: { - e3Program: '0x3', - requester: '0x4', - requestBlock: 1n, - inputWindow: [1n, 2n], - committeeSize: 1, - seed: 1n, - encryptionSchemeId: '0x', - committeePublicKey: '0x', - ciphertextOutput: '0x', - plaintextOutput: '0x1234', - }, - }, -}) - -beforeEach(() => { - vi.resetModules() - vi.resetAllMocks() - mocks.getBlock.mockImplementation(async ({ blockNumber }) => ({ hash: `hash:${blockNumber}`, timestamp: blockNumber })) - mocks.getLogs.mockImplementation(async ({ event, fromBlock, toBlock }) => - event.name === 'E3Requested' ? [round(1n), round(2n)].filter((log) => log.blockNumber >= fromBlock && log.blockNumber <= toBlock) : [], - ) - mocks.multicall.mockImplementation(async ({ contracts }) => - contracts.map(({ args }: any) => ({ status: 'success', result: args[0] === 1n ? 5 : 3 })), - ) -}) - -it('polls only nonterminal stages and fetches only the new event range', async () => { - const { fetchE3List } = await import('../src/lib/e3') - expect((await fetchE3List({ crispOnly: true, toBlock: 10n })).map((row) => row.stage)).toEqual([5, 3]) - await fetchE3List({ crispOnly: true, toBlock: 10n }) - expect(mocks.getLogs).toHaveBeenCalledTimes(2) - expect(mocks.multicall.mock.calls[1][0].contracts.map((contract: any) => contract.args[0])).toEqual([2n]) - expect(mocks.multicall.mock.calls[1][0].blockNumber).toBe(10n) - await fetchE3List({ crispOnly: true, toBlock: 12n }) - expect(mocks.getLogs.mock.calls.slice(2).every(([args]) => args.fromBlock === 11n && args.toBlock === 12n)).toBe(true) -}) - -it('re-reads terminal stages after a reorg', async () => { - const { fetchE3List } = await import('../src/lib/e3') - await fetchE3List({ toBlock: 10n }) - mocks.getBlock.mockImplementation(async ({ blockNumber }) => ({ hash: `replacement:${blockNumber}`, timestamp: blockNumber })) - mocks.multicall.mockImplementation(async ({ contracts }) => contracts.map(() => ({ status: 'success', result: 1 }))) - expect((await fetchE3List({ toBlock: 12n })).map((row) => row.stage)).toEqual([1, 1]) - expect(mocks.multicall.mock.calls[1][0].contracts).toHaveLength(2) - expect(mocks.getLogs.mock.calls[1][0].fromBlock).toBe(1n) -}) - -it('reuses completed result data and request history but refreshes the refundable balance', async () => { - mocks.readContract.mockImplementation(async ({ functionName }) => { - if (functionName === 'getE3') return round(1n).args.e3 - if (functionName === 'getE3Stage') return 5 - if (functionName === 'e3Payments') return 0n - if (functionName === 'getRoundData') return [0n, '0x', 2n] - throw new Error('Unexpected contract read') - }) - const { fetchE3Details, fetchE3List } = await import('../src/lib/e3') - await fetchE3List({ toBlock: 10n }) - const first = await fetchE3Details(1n, 10n) - const logCalls = mocks.getLogs.mock.calls.length - const readCalls = mocks.readContract.mock.calls.length - const second = await fetchE3Details(1n, 10n) - expect(second).toEqual(first) - expect(second.plaintextOutput).toBe('0x1234') - expect(mocks.getLogs).toHaveBeenCalledTimes(logCalls) - expect(mocks.readContract.mock.calls.slice(readCalls).map(([args]) => args.functionName)).toEqual(['e3Payments']) - expect(mocks.readContract.mock.calls.every(([args]) => args.blockNumber === 10n)).toBe(true) -}) diff --git a/packages/interfold-dashboard/tests/event-history.test.ts b/packages/interfold-dashboard/tests/event-history.test.ts deleted file mode 100644 index 78e9bb2907..0000000000 --- a/packages/interfold-dashboard/tests/event-history.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { describe, expect, it, vi } from 'vitest' -import { CanonicalEventHistory, type IndexedLog } from '../src/lib/event-history' - -function fixture(scope = 'chain:deployment') { - const hashes = new Map() - const logs: IndexedLog[] = [] - const client = { - getBlock: vi.fn(async ({ blockNumber }: { blockNumber: bigint }) => ({ hash: hashes.get(blockNumber) ?? `hash:${blockNumber}` })), - getLogs: vi.fn(async ({ fromBlock, toBlock }: Record) => - logs.filter((log) => log.blockNumber! >= (fromBlock as bigint) && log.blockNumber! <= (toBlock as bigint)), - ), - } - const history = new CanonicalEventHistory(client, scope) - const read = (head: bigint, from = 1n) => history.read(head, (snapshot) => snapshot.logs({ address: 'contract', event: 'Event' }, from)) - return { client, history, hashes, logs, read } -} -const log = (blockNumber: bigint, logIndex = 0, blockHash = `hash:${blockNumber}`): IndexedLog => ({ - blockNumber, - logIndex, - blockHash, - transactionHash: `tx:${blockNumber}:${logIndex}`, -}) - -describe('canonical event history', () => { - it('loads history once, deduplicates replayed logs, and extends only after the cursor', async () => { - const f = fixture() - f.logs.push(log(2n), log(2n), log(4n)) - expect(await f.read(10n)).toEqual([log(2n), log(4n)]) - await f.read(10n) - expect(f.client.getLogs).toHaveBeenCalledTimes(1) - f.logs.push(log(11n)) - expect(await f.read(12n)).toEqual([log(2n), log(4n), log(11n)]) - expect(f.client.getLogs).toHaveBeenLastCalledWith({ address: 'contract', event: 'Event', fromBlock: 11n, toBlock: 12n }) - }) - - it('serializes overlapping refreshes without fetching the same range twice', async () => { - const f = fixture() - const [one, two] = await Promise.all([f.read(10n), f.read(12n)]) - expect(one).toEqual([]) - expect(two).toEqual([]) - expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ - [1n, 10n], - [11n, 12n], - ]) - }) - - it('does not advance a cursor or retain memoized values after a failed chunk', async () => { - const f = fixture() - f.client.getLogs.mockResolvedValueOnce([log(2n)]).mockRejectedValueOnce(new Error('Unavailable')) - await expect( - f.history.read(20_000n, async (snapshot) => { - snapshot.set('complete', true) - return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) - }), - ).rejects.toThrow('Unavailable') - expect( - await f.history.read(20_000n, async (snapshot) => { - expect(snapshot.get('complete')).toBeUndefined() - return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) - }), - ).toEqual([]) - expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ - [1n, 9_501n], - [9_502n, 19_002n], - [1n, 9_501n], - [9_502n, 19_002n], - [19_003n, 20_000n], - ]) - }) - - it('rebuilds after a reorg deeper than the polling window and invalidates terminal values', async () => { - const f = fixture() - f.logs.push(log(2n)) - await f.history.read(10_000n, async (snapshot) => { - snapshot.set('complete', true) - return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) - }) - f.hashes.set(10_000n, 'replacement ancestor') - f.logs.splice(0, 1, log(2n, 0, 'replacement block')) - expect( - await f.history.read(10_010n, async (snapshot) => { - expect(snapshot.get('complete')).toBeUndefined() - return snapshot.logs({ address: 'contract', event: 'Event' }, 1n) - }), - ).toEqual([log(2n, 0, 'replacement block')]) - expect(f.client.getLogs.mock.calls[2][0].fromBlock).toBe(1n) - }) - - it('detects a replacement at the same height and a chain that moves during a read', async () => { - const f = fixture() - await f.read(10n) - f.hashes.set(10n, 'replacement') - await f.read(10n) - expect(f.client.getLogs).toHaveBeenCalledTimes(2) - f.client.getLogs.mockImplementationOnce(async () => { - f.hashes.set(12n, 'changed mid-read') - return [] - }) - await expect(f.read(12n)).rejects.toThrow('chain changed') - await f.read(12n) - expect(f.client.getLogs.mock.calls.slice(-2).map(([args]) => args.fromBlock)).toEqual([11n, 11n]) - }) - - it('prepends missing history and supports a moving recent-events window', async () => { - const f = fixture() - f.logs.push(log(3n), log(8n), log(11n)) - expect(await f.read(10n, 7n)).toEqual([log(8n)]) - expect(await f.read(12n, 8n)).toEqual([log(8n), log(11n)]) - expect(await f.read(12n, 1n)).toEqual([log(3n), log(8n), log(11n)]) - expect(f.client.getLogs.mock.calls.map(([args]) => [args.fromBlock, args.toBlock])).toEqual([ - [7n, 10n], - [11n, 12n], - [1n, 6n], - ]) - }) - - it('keeps chains, deployments, event arguments, and historical views separate', async () => { - const one = fixture('one') - const two = fixture('two') - one.logs.push(log(8n)) - two.logs.push(log(9n)) - expect(await one.read(10n)).toEqual([log(8n)]) - expect(await two.read(10n)).toEqual([log(9n)]) - await one.history.read(10n, (snapshot) => snapshot.logs({ address: 'another', event: 'Event', args: { id: 5n } }, 1n)) - expect(one.client.getLogs).toHaveBeenCalledTimes(2) - expect(await one.read(5n)).toEqual([]) - expect(one.client.getLogs).toHaveBeenCalledTimes(3) - }) - - it('discards work interrupted by abort or reset and permits a later retry', async () => { - const f = fixture() - const abort = new AbortController() - await expect( - f.history.read( - 10n, - async (snapshot) => { - await snapshot.logs({ address: 'contract', event: 'Event' }, 1n) - abort.abort() - }, - abort.signal, - ), - ).rejects.toThrow('cancelled') - await f.read(10n) - expect(f.client.getLogs).toHaveBeenCalledTimes(2) - await expect( - f.history.read(12n, async () => { - f.history.reset() - }), - ).rejects.toThrow('cancelled') - await f.read(12n) - expect(f.client.getLogs).toHaveBeenLastCalledWith({ address: 'contract', event: 'Event', fromBlock: 1n, toBlock: 12n }) - }) - - it('rejects unconfirmed and removed events', async () => { - const f = fixture() - f.client.getLogs.mockResolvedValueOnce([{ ...log(2n), blockHash: null }]) - await expect(f.read(10n)).rejects.toThrow('unconfirmed') - f.client.getLogs.mockResolvedValueOnce([{ ...log(2n), removed: true }]) - await expect(f.read(10n)).rejects.toThrow('unconfirmed') - await f.read(10n) - expect(f.client.getLogs.mock.calls.every(([args]) => args.fromBlock === 1n)).toBe(true) - }) -}) diff --git a/packages/interfold-react/package.json b/packages/interfold-react/package.json index 64ffc755d8..06d6cc250c 100644 --- a/packages/interfold-react/package.json +++ b/packages/interfold-react/package.json @@ -15,7 +15,6 @@ "dist" ], "scripts": { - "test": "vitest --run", "build": "tsup", "dev": "tsup --watch", "clean": "rm -rf dist", @@ -47,9 +46,6 @@ "viem": "2.30.6" }, "devDependencies": { - "vitest": "1.6.1", - "react-test-renderer": "18.3.1", - "@types/react-test-renderer": "^18.3.0", "@interfold/config": "workspace:*", "@types/react": "^18.2.0", "tsup": "^8.5.0", diff --git a/packages/interfold-react/src/useInterfoldSDK.ts b/packages/interfold-react/src/useInterfoldSDK.ts index b9b8bd1c12..f1920f1b7d 100644 --- a/packages/interfold-react/src/useInterfoldSDK.ts +++ b/packages/interfold-react/src/useInterfoldSDK.ts @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useWalletClient, usePublicClient } from 'wagmi' import { InterfoldSDK, @@ -74,41 +74,73 @@ export interface UseInterfoldSDKReturn { */ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKReturn => { const [sdk, setSdk] = useState(null) + const [isInitialized, setIsInitialized] = useState(false) const [error, setError] = useState(null) + const sdkRef = useRef(null) const publicClient = usePublicClient() const { data: walletClient } = useWalletClient() - const { interfold, ciphernodeRegistry, feeToken } = config.contracts ?? {} - const { autoConnect, thresholdBfvParamsPresetName } = config + const initializeSDK = useCallback(async () => { + try { + setError(null) - // Each effect owns one SDK instance and releases that instance on cleanup. - useEffect(() => { - // Mirror the external SDK lifecycle into React state. - // eslint-disable-next-line react-hooks/set-state-in-effect - setSdk(null) - setError(null) - if (!autoConnect || !publicClient) return + if (!publicClient) { + throw new Error('Public client not available') + } + + if (sdkRef.current) { + sdkRef.current.cleanup() + } - try { const sdkConfig: SDKConfig = { publicClient, walletClient, - contracts: { - interfold: interfold ?? '0x0000000000000000000000000000000000000000', - ciphernodeRegistry: ciphernodeRegistry ?? '0x0000000000000000000000000000000000000000', - feeToken: feeToken ?? '0x0000000000000000000000000000000000000000', + contracts: config.contracts || { + interfold: '0x0000000000000000000000000000000000000000', + ciphernodeRegistry: '0x0000000000000000000000000000000000000000', + feeToken: '0x0000000000000000000000000000000000000000', }, - thresholdBfvParamsPresetName, + thresholdBfvParamsPresetName: config.thresholdBfvParamsPresetName, } - const instance = new InterfoldSDK(sdkConfig) - setSdk(instance) - return () => instance.cleanup() + + const newSdk = new InterfoldSDK(sdkConfig) + setSdk(newSdk) + sdkRef.current = newSdk + setIsInitialized(true) } catch (err: unknown) { - const message = err instanceof SDKError ? `SDK Error (${err.code}): ${err.message}` : `Failed to initialize SDK: ${err}` - setError(message) + const errorMessage = err instanceof SDKError ? `SDK Error (${err.code}): ${err.message}` : `Failed to initialize SDK: ${err}` + setError(errorMessage) + console.error('SDK initialization failed:', err) + } + }, [publicClient, walletClient, config.contracts, config.thresholdBfvParamsPresetName]) + + // 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]) + + // 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]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (sdkRef.current) { + sdkRef.current.cleanup() + } } - }, [autoConnect, publicClient, walletClient, interfold, ciphernodeRegistry, feeToken, thresholdBfvParamsPresetName]) + }, []) const getThresholdBfvParamsSet = useCallback(async () => { if (!sdk) throw new Error('SDK not initialized') @@ -141,7 +173,7 @@ export const useInterfoldSDK = (config: UseInterfoldSDKConfig): UseInterfoldSDKR return { sdk, - isInitialized: sdk !== null, + isInitialized, error, requestE3, getThresholdBfvParamsSet, diff --git a/packages/interfold-react/tests/useInterfoldSDK.test.ts b/packages/interfold-react/tests/useInterfoldSDK.test.ts deleted file mode 100644 index ea0cdb89c9..0000000000 --- a/packages/interfold-react/tests/useInterfoldSDK.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createElement, useLayoutEffect } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { useInterfoldSDK, type UseInterfoldSDKConfig } from '../src/useInterfoldSDK' - -const mocks = vi.hoisted(() => ({ - publicClient: {} as object | undefined, - walletClient: {} as object | undefined, - instances: [] as { cleanup: ReturnType; config: unknown }[], - fail: false, -})) -vi.mock('wagmi', () => ({ - usePublicClient: () => mocks.publicClient, - useWalletClient: () => ({ data: mocks.walletClient }), -})) -vi.mock('@interfold/sdk', () => ({ - InterfoldSDK: class { - cleanup = vi.fn() - constructor(readonly config: unknown) { - if (mocks.fail) throw new Error('Constructor failed') - mocks.instances.push(this) - } - }, - SDKError: class extends Error {}, - InterfoldEventType: {}, - RegistryEventType: {}, -})) - -let renderer: ReactTestRenderer | undefined -let result: ReturnType -const contracts = { - interfold: '0x1111', - ciphernodeRegistry: '0x2222', - feeToken: '0x3333', -} as const -function Probe({ config = {} }: { config?: Partial }) { - const value = useInterfoldSDK({ autoConnect: true, contracts: { ...contracts }, ...config }) - useLayoutEffect(() => { - result = value - }) - return null -} -const render = (config?: Partial) => - act(() => { - const element = createElement(Probe, { config }) - if (renderer) renderer.update(element) - else renderer = create(element) - }) - -beforeEach(() => { - mocks.publicClient = {} - mocks.walletClient = {} - mocks.instances.length = 0 - mocks.fail = false -}) -afterEach(() => { - act(() => renderer?.unmount()) - renderer = undefined -}) - -describe('SDK lifecycle', () => { - it('retains the instance and subscriptions for identical inline configuration', () => { - render() - const sdk = result.sdk - for (let i = 0; i < 5; i++) render() - expect(result.sdk).toBe(sdk) - expect(result.isInitialized).toBe(true) - expect(mocks.instances).toHaveLength(1) - expect(mocks.instances[0].cleanup).not.toHaveBeenCalled() - }) - - it('releases each old instance once on wallet change, disconnect, and unmount', () => { - render() - mocks.walletClient = {} - render() - mocks.walletClient = undefined - render() - expect(mocks.instances).toHaveLength(3) - expect(mocks.instances[2].config).toMatchObject({ walletClient: undefined }) - act(() => renderer!.unmount()) - renderer = undefined - for (const instance of mocks.instances) expect(instance.cleanup).toHaveBeenCalledTimes(1) - }) - - it('reacts to address, preset, and public-client changes', () => { - render() - render({ contracts: { ...contracts, interfold: '0x4444' } }) - render({ thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512' }) - mocks.publicClient = {} - render({ thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_512' }) - expect(mocks.instances).toHaveLength(4) - expect(mocks.instances.slice(0, 3).every((instance) => instance.cleanup.mock.calls.length === 1)).toBe(true) - }) - - it('clears initialized state on disabled connection, missing client, or constructor failure', () => { - render() - render({ autoConnect: false }) - expect(result.sdk).toBeNull() - render() - mocks.publicClient = undefined - render() - expect(result.isInitialized).toBe(false) - mocks.publicClient = {} - mocks.fail = true - render() - expect(result.sdk).toBeNull() - expect(result.error).toContain('Constructor failed') - expect(mocks.instances).toHaveLength(2) - for (const instance of mocks.instances) expect(instance.cleanup).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/interfold-react/vitest.config.ts b/packages/interfold-react/vitest.config.ts deleted file mode 100644 index b30d93c0a1..0000000000 --- a/packages/interfold-react/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -import { defineConfig } from 'vitest/config' -import { fileURLToPath } from 'node:url' - -export default defineConfig({ - resolve: { - alias: { '@interfold/sdk': fileURLToPath(new URL('../interfold-sdk/src/index.ts', import.meta.url)) }, - }, - test: { include: ['tests/**/*.test.ts'] }, -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2c434c023..03ee51deac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,9 +222,6 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.11 version: 15.5.13 - '@types/react-test-renderer': - specifier: ^18.3.0 - version: 18.3.1 '@vitejs/plugin-react': specifier: ^4.2.1 version: 4.7.0(vite@5.4.21(@types/node@22.7.5)) @@ -243,9 +240,6 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.5.13 version: 0.5.14(@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.6.2))(prettier@3.6.2) - react-test-renderer: - specifier: 18.3.1 - version: 18.3.1(react@18.3.1) tailwindcss: specifier: ^3.4.2 version: 3.4.19(tsx@4.20.6)(yaml@2.8.2) @@ -255,9 +249,6 @@ importers: vite: specifier: ^5.2.0 version: 5.4.21(@types/node@22.7.5) - vitest: - specifier: 1.6.1 - version: 1.6.1(@types/node@22.7.5) examples/CRISP/packages/crisp-contracts: dependencies: @@ -625,9 +616,6 @@ importers: vite: specifier: ^5.4.0 version: 5.4.21(@types/node@22.7.5) - vitest: - specifier: 1.6.1 - version: 1.6.1(@types/node@22.7.5) packages/interfold-mcp: dependencies: @@ -715,21 +703,12 @@ importers: '@types/react': specifier: ^18.2.0 version: 18.3.31 - '@types/react-test-renderer': - specifier: ^18.3.0 - version: 18.3.1 - react-test-renderer: - specifier: 18.3.1 - version: 18.3.1(react@18.3.1) tsup: specifier: 8.5.0 version: 8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) typescript: specifier: 5.8.3 version: 5.8.3 - vitest: - specifier: 1.6.1 - version: 1.6.1(@types/node@22.7.5) packages/interfold-sdk: dependencies: @@ -848,7 +827,7 @@ importers: version: 5.0.2(@openzeppelin/contracts@5.3.0) '@risc0/ethereum': specifier: file:lib/risc0-ethereum - version: file:templates/default/lib/risc0-ethereum + version: risc0-ethereum@file:templates/default/lib/risc0-ethereum '@types/chai': specifier: ^4.2.0 version: 4.3.20 @@ -3409,9 +3388,6 @@ packages: '@reown/appkit@1.7.8': resolution: {integrity: sha512-51kTleozhA618T1UvMghkhKfaPcc9JlKwLJ5uV+riHyvSoWPKPRIa5A6M1Wano5puNyW0s3fwywhyqTHSilkaA==} - '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': - resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} - '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -4499,9 +4475,6 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react-test-renderer@18.3.1': - resolution: {integrity: sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==} - '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -9163,21 +9136,11 @@ packages: peerDependencies: react: '>=16.8' - react-shallow-renderer@16.15.0: - resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 - react-syntax-highlighter@15.6.6: resolution: {integrity: sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==} peerDependencies: react: '>= 0.14.0' - react-test-renderer@18.3.1: - resolution: {integrity: sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==} - peerDependencies: - react: ^18.3.1 - react-transition-state@1.1.5: resolution: {integrity: sha512-ITY2mZqc2dWG2eitJkYNdcSFW8aKeOlkL2A/vowRrLL8GH3J6Re/SpD/BLvQzrVOTqjsP0b5S9N10vgNNzwMUQ==} peerDependencies: @@ -9405,6 +9368,9 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} + risc0-ethereum@file:templates/default/lib/risc0-ethereum: + resolution: {directory: templates/default/lib/risc0-ethereum, type: directory} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -14175,8 +14141,6 @@ snapshots: - utf-8-validate - zod - '@risc0/ethereum@file:templates/default/lib/risc0-ethereum': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/plugin-inject@5.0.5(rollup@4.62.3)': @@ -15444,10 +15408,6 @@ snapshots: dependencies: '@types/react': 18.3.31 - '@types/react-test-renderer@18.3.1': - dependencies: - '@types/react': 18.3.31 - '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -21608,12 +21568,6 @@ snapshots: '@remix-run/router': 1.23.3 react: 18.3.1 - react-shallow-renderer@16.15.0(react@18.3.1): - dependencies: - object-assign: 4.1.1 - react: 18.3.1 - react-is: 18.3.1 - react-syntax-highlighter@15.6.6(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 @@ -21624,13 +21578,6 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-test-renderer@18.3.1(react@18.3.1): - dependencies: - react: 18.3.1 - react-is: 18.3.1 - react-shallow-renderer: 16.15.0(react@18.3.1) - scheduler: 0.23.2 - react-transition-state@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -21894,6 +21841,8 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 + risc0-ethereum@file:templates/default/lib/risc0-ethereum: {} + robust-predicates@3.0.3: {} rollup@4.62.3: