use std::str::FromStr;
use ruint_uniffi::Uint256;
use semaphore_rs::poseidon_tree::Proof;
use serde::{Deserialize, Serialize};
use crate::{error::WalletKitError, http_request::Request};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SequencerBody {
identity_commitment: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InclusionProofResponse {
status: String, root: Uint256,
proof: Proof,
}
const CREDENTIAL_NOT_ISSUED_RESPONSE: &str = "provided identity commitment not found";
const MINED_STATUS: &str = "mined";
#[derive(Debug, uniffi::Object)]
#[allow(clippy::module_name_repetitions)]
pub struct MerkleTreeProof {
poseidon_proof: Proof,
pub merkle_root: Uint256,
}
impl MerkleTreeProof {
#[must_use]
pub const fn as_poseidon_proof(&self) -> &Proof {
&self.poseidon_proof
}
}
#[uniffi::export]
impl MerkleTreeProof {
#[uniffi::constructor]
pub async fn from_identity_commitment(
identity_commitment: &Uint256,
sequencer_host: &str,
require_mined_proof: bool,
) -> Result<Self, WalletKitError> {
let url = format!("{sequencer_host}/inclusionProof");
let body = SequencerBody {
identity_commitment: identity_commitment.to_padded_hex_string(),
};
let request = Request::new();
let http_response = request.handle(request.post(&url).json(&body)).await?;
let status = http_response.status();
let response_text = match http_response.text().await {
Ok(text) => text,
Err(err) => {
return Err(WalletKitError::SerializationError { error: format!(
"[MerkleTreeProof] Failed to read response body from {url} with status {status}: {err}"
) });
}
};
if status == 400 && response_text == CREDENTIAL_NOT_ISSUED_RESPONSE {
return Err(WalletKitError::CredentialNotIssued);
}
match serde_json::from_str::<InclusionProofResponse>(&response_text) {
Ok(response) => {
if require_mined_proof && response.status != MINED_STATUS {
return Err(WalletKitError::CredentialNotMined);
}
Ok(Self {
poseidon_proof: response.proof,
merkle_root: response.root,
})
}
Err(parse_err) => {
Err(WalletKitError::SerializationError { error: format!(
"[MerkleTreeProof] Failed to parse response from {url} with status {status}: {parse_err}, received: {}",
response_text.chars().take(20).collect::<String>()
),
})
}
}
}
#[uniffi::constructor]
pub fn from_json_proof(
json_proof: &str,
merkle_root: &str,
) -> Result<Self, WalletKitError> {
let proof: Proof = serde_json::from_str(json_proof).map_err(|_| {
WalletKitError::SerializationError {
error: "Failed to parse JSON proof".to_string(),
}
})?;
Ok(Self {
poseidon_proof: proof,
merkle_root: Uint256::from_str(merkle_root).map_err(|_| {
WalletKitError::InvalidInput {
attribute: "merkle_root".to_string(),
reason: "invalid number".to_string(),
}
})?,
})
}
}
#[cfg(test)]
mod tests {
use crate::{
v3::{world_id::WorldId, CredentialType},
Environment,
};
use super::*;
#[tokio::test]
async fn test_retrieve_merkle_proof_from_sequencer() {
let mut mock_server = mockito::Server::new_async().await;
mock_server
.mock("POST", "/inclusionProof")
.with_status(200)
.with_body(include_bytes!(
"../../tests/v3/fixtures/inclusion_proof.json"
))
.create_async()
.await;
let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging);
let merkle_proof = MerkleTreeProof::from_identity_commitment(
&world_id.get_identity_commitment(&CredentialType::Device),
&mock_server.url(),
false,
)
.await
.unwrap();
drop(mock_server);
assert_eq!(
merkle_proof.merkle_root,
Uint256::from_str(
"0x2f3a95b6df9074a19bf46e2308d7f5696e9dca49e0d64ef49a1425bbf40e0c02"
)
.unwrap()
);
assert_eq!(merkle_proof.poseidon_proof.leaf_index(), 17_029_704);
}
#[tokio::test]
async fn test_http_error_handling() {
let mut mock_server = mockito::Server::new_async().await;
mock_server
.mock("POST", "/inclusionProof")
.with_status(404)
.with_header("Content-Type", "application/json")
.with_body(r#"{"error":"Identity commitment not found"}"#)
.create_async()
.await;
let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging);
let url = mock_server.url();
let result = MerkleTreeProof::from_identity_commitment(
&world_id.get_identity_commitment(&CredentialType::Device),
&url,
false,
)
.await;
drop(mock_server);
assert!(result.is_err());
if let Err(err) = result {
match err {
WalletKitError::SerializationError { error: msg } => {
assert!(msg.contains("with status 404"));
assert!(msg.contains(&url));
}
_ => panic!("Expected SerializationError, got: {err:?}"),
}
}
}
#[tokio::test]
async fn test_credential_not_issued() {
let mut mock_server = mockito::Server::new_async().await;
mock_server
.mock("POST", "/inclusionProof")
.with_status(400)
.with_body("provided identity commitment not found")
.create_async()
.await;
let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging);
let url = mock_server.url();
let result = MerkleTreeProof::from_identity_commitment(
&world_id.get_identity_commitment(&CredentialType::Device),
&url,
false,
)
.await;
drop(mock_server);
assert!(result.is_err());
if let Err(err) = result {
match err {
WalletKitError::CredentialNotIssued => {}
_ => panic!("Expected CredentialNotIssued, got: {err:?}"),
}
}
}
#[tokio::test]
async fn test_fail_when_mined_proof_is_required_and_identity_is_not_ready() {
let mut mock_server = mockito::Server::new_async().await;
let response = include_str!("../../tests/v3/fixtures/inclusion_proof.json")
.replace("\"mined\"", "\"pending\"");
mock_server
.mock("POST", "/inclusionProof")
.with_status(200)
.with_body(response.as_bytes())
.create_async()
.await;
let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging);
let result = MerkleTreeProof::from_identity_commitment(
&world_id.get_identity_commitment(&CredentialType::Device),
&mock_server.url(),
true,
)
.await
.unwrap_err();
drop(mock_server);
assert!(matches!(result, WalletKitError::CredentialNotMined));
}
}