Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 14 additions & 24 deletions examples/CRISP/crates/evm_helpers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
// or FITNESS FOR A PARTICULAR PURPOSE.

use alloy::{
network::{Ethereum, EthereumWallet}, primitives::{Address, Bytes, U256}, providers::{
network::{Ethereum, EthereumWallet},
primitives::{Address, Bytes, I256, U256},
providers::{
Identity, ProviderBuilder, RootProvider, fillers::{
BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller,
WalletFiller,
Expand All @@ -20,8 +22,7 @@ sol! {
#[sol(rpc)]
contract CRISPProgram {
function setMerkleRoot(uint256 e3_id, uint256 _root) external;
function getSlotIndex(uint256 e3_id, address slot_address) external view returns (uint256);
function isSlotEmptyByAddress(uint256 e3_id, address slot_address) external view returns (bool);
function getSlotIndex(uint256 e3_id, address slot_address) external view returns (int256);
function publishInput(uint256 e3_id, bytes data) external;
}
}
Expand Down Expand Up @@ -128,37 +129,26 @@ impl CRISPContract<CRISPReadProvider> {
})
}

/// Get the slot index from a given slot address
/// Get the slot index from a given slot address.
/// Returns `None` when the slot is empty (contract returns -1).
pub async fn get_slot_index_from_address(
&self,
e3_id: U256,
slot_address: Address,
) -> Result<U256> {
) -> Result<Option<u64>> {
let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref());

match contract.getSlotIndex(e3_id, slot_address).call().await {
Ok(slot_index) => Ok(slot_index),
Ok(slot_index) => {
if slot_index < I256::ZERO {
Ok(None)
} else {
Ok(Some(slot_index.as_u64()))
}
}
Err(e) => Err(eyre::eyre!("Failed to get slot index: {}", e)),
}
}

/// Check if a slot is empty by its address
pub async fn get_is_slot_empty_by_address(
&self,
e3_id: U256,
slot_address: Address,
) -> Result<bool> {
let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref());

match contract
.isSlotEmptyByAddress(e3_id, slot_address)
.call()
.await
{
Ok(is_empty) => Ok(is_empty),
Err(e) => Err(eyre::eyre!("Failed to check if slot is empty: {}", e)),
}
}
}

impl<P> CRISPContract<P> {
Expand Down
17 changes: 3 additions & 14 deletions examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,10 @@ contract CRISPProgram is IE3Program, Ownable {
/// @notice Get the slot index for a given E3 ID and slot address
/// @param e3Id The E3 program ID
/// @param slotAddress The slot address
/// @return The slot index
function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (uint40) {
/// @return The slot index, or -1 if the slot is empty
function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (int40) {
uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress];
if (storedIndexPlusOne == 0) {
revert SlotIsEmpty();
}
return storedIndexPlusOne - 1;
}

/// @notice Check if a slot is empty for a given E3 ID and slot address
/// @param e3Id The E3 program ID
/// @param slotAddress The slot address
/// @return Whether the slot is empty or not
function isSlotEmptyByAddress(uint256 e3Id, address slotAddress) external view returns (bool) {
return e3Data[e3Id].voteSlots[slotAddress] == 0;
return int40(storedIndexPlusOne) - 1;
}

/// @inheritdoc IE3Program
Expand Down
10 changes: 4 additions & 6 deletions examples/CRISP/packages/crisp-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ const address = await getAddressFromSignature(signature, messageHash)
#### State Utilities

```typescript
import { getPreviousCiphertext, getIsSlotEmpty } from '@crisp-e3/sdk'
import { getPreviousCiphertext } from '@crisp-e3/sdk'

const previousCiphertext = await getPreviousCiphertext(serverUrl, e3Id, slotAddress)
const isEmpty = await getIsSlotEmpty(serverUrl, e3Id, slotAddress)
// Returns undefined when the slot is empty (404)
```

## API
Expand All @@ -170,10 +170,8 @@ const isEmpty = await getIsSlotEmpty(serverUrl, e3Id, slotAddress)
- `getRoundDetails(serverUrl: string, e3Id: number): Promise<RoundDetails>` - Get round details
- `getRoundTokenDetails(serverUrl: string, e3Id: number): Promise<TokenDetails>` - Get token details
for a round
- `getPreviousCiphertext(serverUrl: string, e3Id: number, address: string): Promise<Uint8Array>` -
Get previous ciphertext for a slot
- `getIsSlotEmpty(serverUrl: string, e3Id: number, address: string): Promise<boolean>` - Check if a
slot is empty
- `getPreviousCiphertext(serverUrl: string, e3Id: number, address: string): Promise<Uint8Array | undefined>` -
Get previous ciphertext for a slot (undefined when slot is empty)

### Token Functions

Expand Down
1 change: 0 additions & 1 deletion examples/CRISP/packages/crisp-sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { hashMessage } from 'viem'
export const CRISP_SERVER_TOKEN_TREE_ENDPOINT = 'state/token-holders'
export const CRISP_SERVER_STATE_LITE_ENDPOINT = 'state/lite'
export const CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT = 'state/previous-ciphertext'
export const CRISP_SERVER_IS_SLOT_EMPTY_ENDPOINT = 'state/is-slot-empty'

export const MERKLE_TREE_MAX_DEPTH = 20 // static, hardcoded in the circuit.

Expand Down
23 changes: 8 additions & 15 deletions examples/CRISP/packages/crisp-sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE.

import { getIsSlotEmpty, getPreviousCiphertext } from './state'
import { getPreviousCiphertext } from './state'
import { generateMaskVoteProof, generateVoteProof } from './vote'

import type { MaskVoteProofRequest, ProofData, VoteProofRequest } from './types'
Expand Down Expand Up @@ -33,13 +33,7 @@ export class CrispSDK {
* @returns A promise that resolves to the generated proof data.
*/
async generateMaskVoteProof(maskProofInputs: MaskVoteProofRequest): Promise<ProofData> {
const isSlotEmpty = await getIsSlotEmpty(this.serverUrl, maskProofInputs.e3Id, maskProofInputs.slotAddress)

let previousCiphertext

if (!isSlotEmpty) {
previousCiphertext = await getPreviousCiphertext(this.serverUrl, maskProofInputs.e3Id, maskProofInputs.slotAddress)
}
const previousCiphertext = await getPreviousCiphertext(this.serverUrl, maskProofInputs.e3Id, maskProofInputs.slotAddress)

return generateMaskVoteProof({
...maskProofInputs,
Expand All @@ -49,17 +43,16 @@ export class CrispSDK {

/**
* Generate a proof for a vote.
*
* Note: The previous ciphertext is not used in the proof computation. This method still calls
* the same server API (previous-ciphertext) as {@link generateMaskVoteProof} to prevent the
* server from inferring the vote type (mask vs normal) from the client's API usage pattern.
*
* @param voteProofInputs - The inputs required to generate the vote proof.
* @returns A promise that resolves to the generated proof data.
*/
async generateVoteProof(voteProofInputs: VoteProofRequest): Promise<ProofData> {
const isSlotEmpty = await getIsSlotEmpty(this.serverUrl, voteProofInputs.e3Id, voteProofInputs.slotAddress)

let previousCiphertext

if (!isSlotEmpty) {
previousCiphertext = await getPreviousCiphertext(this.serverUrl, voteProofInputs.e3Id, voteProofInputs.slotAddress)
}
const previousCiphertext = await getPreviousCiphertext(this.serverUrl, voteProofInputs.e3Id, voteProofInputs.slotAddress)

return generateVoteProof({
...voteProofInputs,
Expand Down
43 changes: 10 additions & 33 deletions examples/CRISP/packages/crisp-sdk/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@
// without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE.

import {
CRISP_SERVER_STATE_LITE_ENDPOINT,
CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT,
CRISP_SERVER_IS_SLOT_EMPTY_ENDPOINT,
} from './constants'
import { CRISP_SERVER_STATE_LITE_ENDPOINT, CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT } from './constants'

import type { RoundDetailsResponse, RoundDetails, TokenDetails } from './types'

Expand Down Expand Up @@ -59,13 +55,15 @@ export const getRoundTokenDetails = async (serverUrl: string, e3Id: number): Pro
}

/**
* Get the previous ciphertext for a slot from the CRISP server
* Get the previous ciphertext for a slot from the CRISP server.
* Returns undefined when the slot is empty (404).
*
* @param serverUrl - The base URL of the CRISP server
* @param e3Id - The e3Id of the round
* @param address - The address of the slot
* @returns The previous ciphertext for the slot
* @returns The previous ciphertext for the slot, or undefined if the slot is empty
*/
export const getPreviousCiphertext = async (serverUrl: string, e3Id: number, address: string): Promise<Uint8Array> => {
export const getPreviousCiphertext = async (serverUrl: string, e3Id: number, address: string): Promise<Uint8Array | undefined> => {
const response = await fetch(`${serverUrl}/${CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT}`, {
method: 'POST',
headers: {
Expand All @@ -74,36 +72,15 @@ export const getPreviousCiphertext = async (serverUrl: string, e3Id: number, add
body: JSON.stringify({ round_id: e3Id, address }),
})

if (!response.ok) {
throw new Error(`Failed to fetch previous ciphertext: ${response.statusText}`)
if (response.status === 404) {
return undefined
}

const data = await response.json()

return new Uint8Array(data.ciphertext)
}

/**
* Check if a slot is empty for a given E3 ID and slot address
* @param serverUrl - The base URL of the CRISP server
* @param e3Id - The e3Id of the round
* @param address - The address of the slot
* @returns Whether the slot is empty or not
*/
export const getIsSlotEmpty = async (serverUrl: string, e3Id: number, address: string): Promise<boolean> => {
const response = await fetch(`${serverUrl}/${CRISP_SERVER_IS_SLOT_EMPTY_ENDPOINT}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ round_id: e3Id, address }),
})

if (!response.ok) {
throw new Error(`Failed to check if slot is empty: ${response.statusText}`)
throw new Error(`Failed to fetch previous ciphertext: ${response.statusText}`)
}

const data = await response.json()

return data.is_empty as boolean
return new Uint8Array(data.ciphertext)
}
16 changes: 6 additions & 10 deletions examples/CRISP/packages/crisp-sdk/tests/vote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,12 @@ describe('Vote', () => {
const mockGetPreviousCiphertextResponse = () =>
({
ok: true,
status: 200,
json: async () => ({ ciphertext: previousCiphertext }),
}) as Response

const mockIsSlotEmptyResponse = (isEmpty: boolean) =>
({
ok: true,
json: async () => ({ is_empty: isEmpty }),
}) as Response
const mockPreviousCiphertextNotFoundResponse = () =>
({ ok: false, status: 404 }) as Response

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -151,7 +149,7 @@ describe('Vote', () => {

describe('generateVoteProof', () => {
it('Should generate a valid vote proof', { timeout: 300000 }, async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockIsSlotEmptyResponse(true))
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockPreviousCiphertextNotFoundResponse())

const proof = await sdk.generateVoteProof({
vote,
Expand Down Expand Up @@ -181,7 +179,7 @@ describe('Vote', () => {

describe('generateMaskVoteProof', () => {
it('Should generate a valid mask vote proof when there are no votes in the slot', { timeout: 300000 }, async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockIsSlotEmptyResponse(true))
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockPreviousCiphertextNotFoundResponse())

const proof = await sdk.generateMaskVoteProof({
balance,
Expand All @@ -207,9 +205,7 @@ describe('Vote', () => {
})

it('Should generate a valid mask vote proof when there is a previous vote in the slot', { timeout: 300000 }, async () => {
vi.spyOn(global, 'fetch')
.mockResolvedValueOnce(mockIsSlotEmptyResponse(false))
.mockResolvedValueOnce(mockGetPreviousCiphertextResponse())
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockGetPreviousCiphertextResponse())

const proof = await sdk.generateMaskVoteProof({
balance,
Expand Down
2 changes: 1 addition & 1 deletion examples/CRISP/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
############### stage 0: base-dev ###############
ARG RUST_VERSION=1.86.0
ARG RUST_VERSION=1.88.0
ARG SKIP_SOLIDITY=0

FROM rust:${RUST_VERSION}-slim-bullseye AS base-dev
Expand Down
11 changes: 0 additions & 11 deletions examples/CRISP/server/src/server/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,6 @@ pub struct PreviousCiphertextResponse {
pub ciphertext: Vec<u8>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct IsSlotEmptyRequest {
pub round_id: u64,
pub address: String,
}

#[derive(Serialize)]
pub struct IsSlotEmptyResponse {
pub is_empty: bool,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ComputeProviderParams {
pub name: String,
Expand Down
49 changes: 5 additions & 44 deletions examples/CRISP/server/src/server/routes/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use std::str::FromStr;
use crate::server::{
app_data::AppData,
models::{
GetRoundRequest, IsSlotEmptyRequest, IsSlotEmptyResponse, PreviousCiphertextRequest,
PreviousCiphertextResponse, RoundRequestWithRequester, WebhookPayload,
GetRoundRequest, PreviousCiphertextRequest, PreviousCiphertextResponse,
RoundRequestWithRequester, WebhookPayload,
},
CONFIG,
};
Expand Down Expand Up @@ -40,8 +40,7 @@ pub fn setup_routes(config: &mut web::ServiceConfig) {
.route(
"/previous-ciphertext",
web::post().to(handle_get_previous_ciphertext),
)
.route("/is-slot-empty", web::post().to(handle_is_slot_empty)),
),
);
}

Expand Down Expand Up @@ -81,7 +80,8 @@ async fn handle_get_previous_ciphertext(
.get_slot_index_from_address(U256::from(incoming.round_id), address)
.await
{
Ok(index) => index.to::<u64>(),
Ok(Some(index)) => index,
Ok(None) => return HttpResponse::NotFound().body("Ciphertext not found"),
Err(e) => {
error!("Error getting slot index from address: {:?}", e);
return HttpResponse::InternalServerError()
Expand Down Expand Up @@ -302,45 +302,6 @@ async fn get_token_holders_hashes(
}
}

/// Check if a slot is empty given an address
/// # Arguments
/// * `IsSlotEmptyRequest` - The request containing round_id and address
async fn handle_is_slot_empty(data: web::Json<IsSlotEmptyRequest>) -> impl Responder {
let incoming = data.into_inner();

let contract =
match CRISPContractFactory::create_read(&CONFIG.http_rpc_url, &CONFIG.e3_program_address)
.await
{
Ok(contract) => contract,
Err(e) => {
error!("Failed to create CRISP contract: {:?}", e);
return HttpResponse::InternalServerError().body("Failed to create CRISP contract");
}
};

let address = match Address::from_str(incoming.address.as_str()) {
Ok(addr) => addr,
Err(e) => {
error!("Invalid address format: {:?}", e);
return HttpResponse::BadRequest().body("Invalid address format");
}
};

let is_empty = match contract
.get_is_slot_empty_by_address(U256::from(incoming.round_id), address)
.await
{
Ok(empty) => empty,
Err(e) => {
error!("Error checking if slot is empty: {:?}", e);
return HttpResponse::InternalServerError().body("Failed to check if slot is empty");
}
};

HttpResponse::Ok().json(IsSlotEmptyResponse { is_empty })
}

/// Get the eligible addresses for a given round
/// # Arguments
/// * `GetRoundRequest` - The request data containing the round ID
Expand Down
Loading