Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
302
vendor/ruvector/examples/edge/src/plaid/mod.rs
vendored
Normal file
302
vendor/ruvector/examples/edge/src/plaid/mod.rs
vendored
Normal file
@@ -0,0 +1,302 @@
|
||||
//! Plaid API Integration with Browser-Local Learning
|
||||
//!
|
||||
//! This module provides privacy-preserving financial data analysis that runs entirely
|
||||
//! in the browser. No financial data, learning patterns, or AI models ever leave the
|
||||
//! client device.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! - `zkproofs` - Zero-knowledge proofs for financial statements
|
||||
//! - `wasm` - WASM bindings for browser integration
|
||||
//! - `zk_wasm` - WASM bindings for ZK proofs
|
||||
|
||||
pub mod zkproofs;
|
||||
pub mod zkproofs_prod;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod zk_wasm;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod zk_wasm_prod;
|
||||
|
||||
// Re-export demo ZK types (for backward compatibility)
|
||||
pub use zkproofs::{
|
||||
ZkProof, ProofType, VerificationResult, Commitment,
|
||||
FinancialProofBuilder, RentalApplicationProof,
|
||||
};
|
||||
|
||||
// Re-export production ZK types
|
||||
pub use zkproofs_prod::{
|
||||
PedersenCommitment, ZkRangeProof, ProofMetadata,
|
||||
VerificationResult as ProdVerificationResult,
|
||||
FinancialProver, FinancialVerifier, RentalApplicationBundle,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Financial transaction from Plaid
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transaction {
|
||||
pub transaction_id: String,
|
||||
pub account_id: String,
|
||||
pub amount: f64,
|
||||
pub date: String,
|
||||
pub name: String,
|
||||
pub merchant_name: Option<String>,
|
||||
pub category: Vec<String>,
|
||||
pub pending: bool,
|
||||
pub payment_channel: String,
|
||||
}
|
||||
|
||||
/// Spending pattern learned from transactions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpendingPattern {
|
||||
pub pattern_id: String,
|
||||
pub category: String,
|
||||
pub avg_amount: f64,
|
||||
pub frequency_days: f32,
|
||||
pub confidence: f64,
|
||||
pub last_seen: u64,
|
||||
}
|
||||
|
||||
/// Category prediction result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CategoryPrediction {
|
||||
pub category: String,
|
||||
pub confidence: f64,
|
||||
pub similar_transactions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Anomaly detection result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnomalyResult {
|
||||
pub is_anomaly: bool,
|
||||
pub anomaly_score: f64,
|
||||
pub reason: String,
|
||||
pub expected_amount: f64,
|
||||
}
|
||||
|
||||
/// Budget recommendation from learning
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BudgetRecommendation {
|
||||
pub category: String,
|
||||
pub recommended_limit: f64,
|
||||
pub current_avg: f64,
|
||||
pub trend: String, // "increasing", "stable", "decreasing"
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
/// Local learning state for financial patterns
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FinancialLearningState {
|
||||
pub version: u64,
|
||||
pub patterns: HashMap<String, SpendingPattern>,
|
||||
/// Category embeddings - HashMap prevents unbounded growth (was Vec which leaked memory)
|
||||
pub category_embeddings: HashMap<String, Vec<f32>>,
|
||||
pub q_values: HashMap<String, f64>, // state|action -> Q-value
|
||||
pub temporal_weights: Vec<f32>, // Day-of-week weights (7 days: Sun-Sat)
|
||||
pub monthly_weights: Vec<f32>, // Day-of-month weights (31 days)
|
||||
/// Maximum embeddings to store (LRU eviction when exceeded)
|
||||
#[serde(default = "default_max_embeddings")]
|
||||
pub max_embeddings: usize,
|
||||
}
|
||||
|
||||
fn default_max_embeddings() -> usize {
|
||||
10_000 // ~400KB at 10 floats per embedding
|
||||
}
|
||||
|
||||
impl Default for FinancialLearningState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: 0,
|
||||
patterns: HashMap::new(),
|
||||
category_embeddings: HashMap::new(),
|
||||
q_values: HashMap::new(),
|
||||
temporal_weights: vec![1.0; 7], // 7 days
|
||||
monthly_weights: vec![1.0; 31], // 31 days
|
||||
max_embeddings: default_max_embeddings(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transaction feature vector for ML
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransactionFeatures {
|
||||
pub amount_normalized: f32,
|
||||
pub day_of_week: f32,
|
||||
pub day_of_month: f32,
|
||||
pub hour_of_day: f32,
|
||||
pub is_weekend: f32,
|
||||
pub category_hash: Vec<f32>, // LSH of category text
|
||||
pub merchant_hash: Vec<f32>, // LSH of merchant name
|
||||
}
|
||||
|
||||
impl TransactionFeatures {
|
||||
/// Convert to embedding vector for HNSW indexing
|
||||
pub fn to_embedding(&self) -> Vec<f32> {
|
||||
let mut vec = vec![
|
||||
self.amount_normalized,
|
||||
self.day_of_week / 7.0,
|
||||
self.day_of_month / 31.0,
|
||||
self.hour_of_day / 24.0,
|
||||
self.is_weekend,
|
||||
];
|
||||
vec.extend(&self.category_hash);
|
||||
vec.extend(&self.merchant_hash);
|
||||
vec
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract features from a transaction
|
||||
pub fn extract_features(tx: &Transaction) -> TransactionFeatures {
|
||||
// Parse date for temporal features
|
||||
let (dow, dom, _hour) = parse_date(&tx.date);
|
||||
|
||||
// Normalize amount (log scale, clipped)
|
||||
let amount_normalized = (tx.amount.abs().ln() / 10.0).min(1.0) as f32;
|
||||
|
||||
// LSH hash for category
|
||||
let category_text = tx.category.join(" ");
|
||||
let category_hash = simple_lsh(&category_text, 8);
|
||||
|
||||
// LSH hash for merchant
|
||||
let merchant = tx.merchant_name.as_deref().unwrap_or(&tx.name);
|
||||
let merchant_hash = simple_lsh(merchant, 8);
|
||||
|
||||
TransactionFeatures {
|
||||
amount_normalized,
|
||||
day_of_week: dow as f32,
|
||||
day_of_month: dom as f32,
|
||||
hour_of_day: 12.0, // Default to noon if no time
|
||||
is_weekend: if dow >= 5 { 1.0 } else { 0.0 },
|
||||
category_hash,
|
||||
merchant_hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple LSH (locality-sensitive hashing) for text
|
||||
fn simple_lsh(text: &str, dims: usize) -> Vec<f32> {
|
||||
let mut hash = vec![0.0f32; dims];
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
for (i, c) in text_lower.chars().enumerate() {
|
||||
let idx = (c as usize + i * 31) % dims;
|
||||
hash[idx] += 1.0;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let norm: f32 = hash.iter().map(|x| x * x).sum::<f32>().sqrt().max(1.0);
|
||||
hash.iter_mut().for_each(|x| *x /= norm);
|
||||
|
||||
hash
|
||||
}
|
||||
|
||||
/// Parse date string to (day_of_week, day_of_month, hour)
|
||||
fn parse_date(date_str: &str) -> (u8, u8, u8) {
|
||||
// Simple parser for YYYY-MM-DD format
|
||||
let parts: Vec<&str> = date_str.split('-').collect();
|
||||
if parts.len() >= 3 {
|
||||
let day: u8 = parts[2].parse().unwrap_or(1);
|
||||
let month: u8 = parts[1].parse().unwrap_or(1);
|
||||
let year: u16 = parts[0].parse().unwrap_or(2024);
|
||||
|
||||
// Simple day-of-week calculation (Zeller's congruence simplified)
|
||||
let dow = ((day as u16 + 13 * (month as u16 + 1) / 5 + year + year / 4) % 7) as u8;
|
||||
|
||||
(dow, day, 12) // Default hour
|
||||
} else {
|
||||
(0, 1, 12)
|
||||
}
|
||||
}
|
||||
|
||||
/// Q-learning update for spending decisions
|
||||
pub fn update_q_value(
|
||||
state: &FinancialLearningState,
|
||||
category: &str,
|
||||
action: &str, // "under_budget", "at_budget", "over_budget"
|
||||
reward: f64,
|
||||
learning_rate: f64,
|
||||
) -> f64 {
|
||||
let key = format!("{}|{}", category, action);
|
||||
let current_q = state.q_values.get(&key).copied().unwrap_or(0.0);
|
||||
|
||||
// Q-learning update: Q(s,a) = Q(s,a) + α * (r - Q(s,a))
|
||||
current_q + learning_rate * (reward - current_q)
|
||||
}
|
||||
|
||||
/// Generate spending recommendation based on learned Q-values
|
||||
pub fn get_recommendation(
|
||||
state: &FinancialLearningState,
|
||||
category: &str,
|
||||
current_spending: f64,
|
||||
budget: f64,
|
||||
) -> BudgetRecommendation {
|
||||
let ratio = current_spending / budget.max(1.0);
|
||||
|
||||
let actions = ["under_budget", "at_budget", "over_budget"];
|
||||
let mut best_action = "at_budget";
|
||||
let mut best_q = f64::NEG_INFINITY;
|
||||
|
||||
for action in &actions {
|
||||
let key = format!("{}|{}", category, action);
|
||||
if let Some(&q) = state.q_values.get(&key) {
|
||||
if q > best_q {
|
||||
best_q = q;
|
||||
best_action = action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trend = if ratio < 0.8 {
|
||||
"decreasing"
|
||||
} else if ratio > 1.2 {
|
||||
"increasing"
|
||||
} else {
|
||||
"stable"
|
||||
};
|
||||
|
||||
BudgetRecommendation {
|
||||
category: category.to_string(),
|
||||
recommended_limit: budget * best_q.max(0.5).min(2.0),
|
||||
current_avg: current_spending,
|
||||
trend: trend.to_string(),
|
||||
confidence: (1.0 - 1.0 / (state.version as f64 + 1.0)).max(0.1),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_features() {
|
||||
let tx = Transaction {
|
||||
transaction_id: "tx123".to_string(),
|
||||
account_id: "acc456".to_string(),
|
||||
amount: 50.0,
|
||||
date: "2024-03-15".to_string(),
|
||||
name: "Coffee Shop".to_string(),
|
||||
merchant_name: Some("Starbucks".to_string()),
|
||||
category: vec!["Food".to_string(), "Coffee".to_string()],
|
||||
pending: false,
|
||||
payment_channel: "in_store".to_string(),
|
||||
};
|
||||
|
||||
let features = extract_features(&tx);
|
||||
assert!(features.amount_normalized >= 0.0);
|
||||
assert!(features.amount_normalized <= 1.0);
|
||||
assert_eq!(features.category_hash.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_q_learning() {
|
||||
let state = FinancialLearningState::default();
|
||||
|
||||
let new_q = update_q_value(&state, "Food", "under_budget", 1.0, 0.1);
|
||||
assert!(new_q > 0.0);
|
||||
}
|
||||
}
|
||||
334
vendor/ruvector/examples/edge/src/plaid/wasm.rs
vendored
Normal file
334
vendor/ruvector/examples/edge/src/plaid/wasm.rs
vendored
Normal file
@@ -0,0 +1,334 @@
|
||||
//! WASM bindings for Plaid local learning
|
||||
//!
|
||||
//! Exposes browser-local financial learning to JavaScript.
|
||||
|
||||
#![cfg(feature = "wasm")]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use super::{
|
||||
Transaction, SpendingPattern, CategoryPrediction, AnomalyResult,
|
||||
BudgetRecommendation, FinancialLearningState, TransactionFeatures,
|
||||
extract_features, update_q_value, get_recommendation,
|
||||
};
|
||||
|
||||
/// Browser-local financial learning engine
|
||||
///
|
||||
/// All data stays in the browser. Uses IndexedDB for persistence.
|
||||
#[wasm_bindgen]
|
||||
pub struct PlaidLocalLearner {
|
||||
state: Arc<RwLock<FinancialLearningState>>,
|
||||
hnsw_index: crate::WasmHnswIndex,
|
||||
spiking_net: crate::WasmSpikingNetwork,
|
||||
learning_rate: f64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl PlaidLocalLearner {
|
||||
/// Create a new local learner
|
||||
///
|
||||
/// All learning happens in-browser with no data exfiltration.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(FinancialLearningState::default())),
|
||||
hnsw_index: crate::WasmHnswIndex::new(),
|
||||
spiking_net: crate::WasmSpikingNetwork::new(21, 32, 8), // Features -> hidden -> categories
|
||||
learning_rate: 0.1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load state from serialized JSON (from IndexedDB)
|
||||
#[wasm_bindgen(js_name = loadState)]
|
||||
pub fn load_state(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let loaded: FinancialLearningState = serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
*self.state.write() = loaded;
|
||||
|
||||
// Rebuild HNSW index from loaded embeddings
|
||||
let state = self.state.read();
|
||||
for (id, embedding) in &state.category_embeddings {
|
||||
self.hnsw_index.insert(id, embedding.clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize state to JSON (for IndexedDB persistence)
|
||||
#[wasm_bindgen(js_name = saveState)]
|
||||
pub fn save_state(&self) -> Result<String, JsValue> {
|
||||
let state = self.state.read();
|
||||
serde_json::to_string(&*state)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialize error: {}", e)))
|
||||
}
|
||||
|
||||
/// Process a batch of transactions and learn patterns
|
||||
///
|
||||
/// Returns updated insights without sending data anywhere.
|
||||
#[wasm_bindgen(js_name = processTransactions)]
|
||||
pub fn process_transactions(&mut self, transactions_json: &str) -> Result<JsValue, JsValue> {
|
||||
let transactions: Vec<Transaction> = serde_json::from_str(transactions_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
let mut state = self.state.write();
|
||||
let mut insights = ProcessingInsights::default();
|
||||
|
||||
for tx in &transactions {
|
||||
// Extract features
|
||||
let features = extract_features(tx);
|
||||
let embedding = features.to_embedding();
|
||||
|
||||
// Add to HNSW index for similarity search
|
||||
self.hnsw_index.insert(&tx.transaction_id, embedding.clone());
|
||||
|
||||
// Update category embedding (HashMap prevents memory leak - overwrites existing)
|
||||
let category_key = tx.category.join(":");
|
||||
|
||||
// LRU-style eviction if at capacity
|
||||
if state.category_embeddings.len() >= state.max_embeddings {
|
||||
// Remove oldest entry (in production, use proper LRU cache)
|
||||
if let Some(key) = state.category_embeddings.keys().next().cloned() {
|
||||
state.category_embeddings.remove(&key);
|
||||
}
|
||||
}
|
||||
state.category_embeddings.insert(category_key.clone(), embedding.clone());
|
||||
|
||||
// Learn spending pattern
|
||||
self.learn_pattern(&mut state, tx, &features);
|
||||
|
||||
// Update temporal weights
|
||||
let dow = features.day_of_week as usize % 7;
|
||||
let dom = (features.day_of_month as usize).saturating_sub(1) % 31;
|
||||
state.temporal_weights[dow] += 0.1 * (tx.amount.abs() as f32);
|
||||
state.monthly_weights[dom] += 0.1 * (tx.amount.abs() as f32);
|
||||
|
||||
// Feed to spiking network for temporal learning
|
||||
let spike_input = self.features_to_spikes(&features);
|
||||
let _output = self.spiking_net.forward(spike_input);
|
||||
|
||||
insights.transactions_processed += 1;
|
||||
insights.total_amount += tx.amount.abs();
|
||||
}
|
||||
|
||||
state.version += 1;
|
||||
insights.patterns_learned = state.patterns.len();
|
||||
insights.state_version = state.version;
|
||||
|
||||
serde_wasm_bindgen::to_value(&insights)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Predict category for a new transaction
|
||||
#[wasm_bindgen(js_name = predictCategory)]
|
||||
pub fn predict_category(&self, transaction_json: &str) -> Result<JsValue, JsValue> {
|
||||
let tx: Transaction = serde_json::from_str(transaction_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
let features = extract_features(&tx);
|
||||
let embedding = features.to_embedding();
|
||||
|
||||
// Find similar transactions via HNSW
|
||||
let results = self.hnsw_index.search(embedding.clone(), 5);
|
||||
|
||||
// Aggregate category votes from similar transactions
|
||||
let prediction = CategoryPrediction {
|
||||
category: tx.category.first().cloned().unwrap_or_default(),
|
||||
confidence: 0.85,
|
||||
similar_transactions: vec![], // Would populate from results
|
||||
};
|
||||
|
||||
serde_wasm_bindgen::to_value(&prediction)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Detect if a transaction is anomalous
|
||||
#[wasm_bindgen(js_name = detectAnomaly)]
|
||||
pub fn detect_anomaly(&self, transaction_json: &str) -> Result<JsValue, JsValue> {
|
||||
let tx: Transaction = serde_json::from_str(transaction_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
let state = self.state.read();
|
||||
let category_key = tx.category.join(":");
|
||||
|
||||
let result = if let Some(pattern) = state.patterns.get(&category_key) {
|
||||
let amount_diff = (tx.amount.abs() - pattern.avg_amount).abs();
|
||||
let threshold = pattern.avg_amount * 2.0;
|
||||
|
||||
AnomalyResult {
|
||||
is_anomaly: amount_diff > threshold,
|
||||
anomaly_score: amount_diff / pattern.avg_amount.max(1.0),
|
||||
reason: if amount_diff > threshold {
|
||||
format!("Amount ${:.2} is {:.1}x typical", tx.amount, amount_diff / pattern.avg_amount.max(1.0))
|
||||
} else {
|
||||
"Normal transaction".to_string()
|
||||
},
|
||||
expected_amount: pattern.avg_amount,
|
||||
}
|
||||
} else {
|
||||
AnomalyResult {
|
||||
is_anomaly: false,
|
||||
anomaly_score: 0.0,
|
||||
reason: "First transaction in this category".to_string(),
|
||||
expected_amount: tx.amount.abs(),
|
||||
}
|
||||
};
|
||||
|
||||
serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Get budget recommendation for a category
|
||||
#[wasm_bindgen(js_name = getBudgetRecommendation)]
|
||||
pub fn get_budget_recommendation(
|
||||
&self,
|
||||
category: &str,
|
||||
current_spending: f64,
|
||||
budget: f64,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
let state = self.state.read();
|
||||
let rec = get_recommendation(&state, category, current_spending, budget);
|
||||
|
||||
serde_wasm_bindgen::to_value(&rec)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Record spending outcome for Q-learning
|
||||
#[wasm_bindgen(js_name = recordOutcome)]
|
||||
pub fn record_outcome(&mut self, category: &str, action: &str, reward: f64) {
|
||||
let mut state = self.state.write();
|
||||
let key = format!("{}|{}", category, action);
|
||||
let new_q = update_q_value(&state, category, action, reward, self.learning_rate);
|
||||
state.q_values.insert(key, new_q);
|
||||
state.version += 1;
|
||||
}
|
||||
|
||||
/// Get spending patterns summary
|
||||
#[wasm_bindgen(js_name = getPatternsSummary)]
|
||||
pub fn get_patterns_summary(&self) -> Result<JsValue, JsValue> {
|
||||
let state = self.state.read();
|
||||
|
||||
let summary: Vec<SpendingPattern> = state.patterns.values().cloned().collect();
|
||||
|
||||
serde_wasm_bindgen::to_value(&summary)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Get temporal spending heatmap (day of week + day of month)
|
||||
#[wasm_bindgen(js_name = getTemporalHeatmap)]
|
||||
pub fn get_temporal_heatmap(&self) -> Result<JsValue, JsValue> {
|
||||
let state = self.state.read();
|
||||
|
||||
let heatmap = TemporalHeatmap {
|
||||
day_of_week: state.temporal_weights.clone(),
|
||||
day_of_month: state.monthly_weights.clone(),
|
||||
};
|
||||
|
||||
serde_wasm_bindgen::to_value(&heatmap)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Find similar transactions to a given one
|
||||
#[wasm_bindgen(js_name = findSimilarTransactions)]
|
||||
pub fn find_similar_transactions(&self, transaction_json: &str, k: usize) -> JsValue {
|
||||
let Ok(tx) = serde_json::from_str::<Transaction>(transaction_json) else {
|
||||
return JsValue::NULL;
|
||||
};
|
||||
|
||||
let features = extract_features(&tx);
|
||||
let embedding = features.to_embedding();
|
||||
|
||||
self.hnsw_index.search(embedding, k)
|
||||
}
|
||||
|
||||
/// Get current learning statistics
|
||||
#[wasm_bindgen(js_name = getStats)]
|
||||
pub fn get_stats(&self) -> Result<JsValue, JsValue> {
|
||||
let state = self.state.read();
|
||||
|
||||
let stats = LearningStats {
|
||||
version: state.version,
|
||||
patterns_count: state.patterns.len(),
|
||||
q_values_count: state.q_values.len(),
|
||||
embeddings_count: state.category_embeddings.len(),
|
||||
index_size: self.hnsw_index.len(),
|
||||
};
|
||||
|
||||
serde_wasm_bindgen::to_value(&stats)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Clear all learned data (privacy feature)
|
||||
#[wasm_bindgen]
|
||||
pub fn clear(&mut self) {
|
||||
*self.state.write() = FinancialLearningState::default();
|
||||
self.hnsw_index = crate::WasmHnswIndex::new();
|
||||
self.spiking_net.reset();
|
||||
}
|
||||
|
||||
// Internal helper methods
|
||||
|
||||
fn learn_pattern(&self, state: &mut FinancialLearningState, tx: &Transaction, features: &TransactionFeatures) {
|
||||
let category_key = tx.category.join(":");
|
||||
|
||||
let pattern = state.patterns.entry(category_key.clone()).or_insert_with(|| {
|
||||
SpendingPattern {
|
||||
pattern_id: format!("pat_{}", category_key),
|
||||
category: category_key.clone(),
|
||||
avg_amount: 0.0,
|
||||
frequency_days: 30.0,
|
||||
confidence: 0.0,
|
||||
last_seen: 0,
|
||||
}
|
||||
});
|
||||
|
||||
// Exponential moving average for amount
|
||||
pattern.avg_amount = pattern.avg_amount * 0.9 + tx.amount.abs() * 0.1;
|
||||
pattern.confidence = (pattern.confidence + 0.1).min(1.0);
|
||||
|
||||
// Simple timestamp (would use actual timestamp in production)
|
||||
pattern.last_seen = state.version;
|
||||
}
|
||||
|
||||
fn features_to_spikes(&self, features: &TransactionFeatures) -> Vec<u8> {
|
||||
let embedding = features.to_embedding();
|
||||
|
||||
// Convert floats to spike train (probability encoding)
|
||||
embedding.iter().map(|&v| {
|
||||
if v > 0.5 { 1 } else { 0 }
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlaidLocalLearner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct ProcessingInsights {
|
||||
transactions_processed: usize,
|
||||
total_amount: f64,
|
||||
patterns_learned: usize,
|
||||
state_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TemporalHeatmap {
|
||||
day_of_week: Vec<f32>,
|
||||
day_of_month: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct LearningStats {
|
||||
version: u64,
|
||||
patterns_count: usize,
|
||||
q_values_count: usize,
|
||||
embeddings_count: usize,
|
||||
index_size: usize,
|
||||
}
|
||||
322
vendor/ruvector/examples/edge/src/plaid/zk_wasm.rs
vendored
Normal file
322
vendor/ruvector/examples/edge/src/plaid/zk_wasm.rs
vendored
Normal file
@@ -0,0 +1,322 @@
|
||||
//! WASM bindings for Zero-Knowledge Financial Proofs
|
||||
//!
|
||||
//! Generate and verify ZK proofs entirely in the browser.
|
||||
|
||||
#![cfg(feature = "wasm")]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::zkproofs::{
|
||||
FinancialProofBuilder, RangeProof, RentalApplicationProof,
|
||||
ZkProof, VerificationResult, ProofType,
|
||||
};
|
||||
|
||||
/// WASM-compatible ZK Financial Proof Generator
|
||||
///
|
||||
/// All proof generation happens in the browser.
|
||||
/// Private financial data never leaves the client.
|
||||
#[wasm_bindgen]
|
||||
pub struct ZkFinancialProver {
|
||||
builder: FinancialProofBuilder,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ZkFinancialProver {
|
||||
/// Create a new prover instance
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
builder: FinancialProofBuilder::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load income data (array of monthly income in cents)
|
||||
#[wasm_bindgen(js_name = loadIncome)]
|
||||
pub fn load_income(&mut self, monthly_income: Vec<u64>) {
|
||||
self.builder = std::mem::take(&mut self.builder)
|
||||
.with_income(monthly_income);
|
||||
}
|
||||
|
||||
/// Load expense data for a category
|
||||
#[wasm_bindgen(js_name = loadExpenses)]
|
||||
pub fn load_expenses(&mut self, category: &str, monthly_expenses: Vec<u64>) {
|
||||
self.builder = std::mem::take(&mut self.builder)
|
||||
.with_expenses(category, monthly_expenses);
|
||||
}
|
||||
|
||||
/// Load balance history (array of daily balances in cents, can be negative)
|
||||
#[wasm_bindgen(js_name = loadBalances)]
|
||||
pub fn load_balances(&mut self, daily_balances: Vec<i64>) {
|
||||
self.builder = std::mem::take(&mut self.builder)
|
||||
.with_balances(daily_balances);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Proof Generation
|
||||
// ========================================================================
|
||||
|
||||
/// Prove: average income ≥ threshold
|
||||
///
|
||||
/// Returns serialized ZkProof or error string
|
||||
#[wasm_bindgen(js_name = proveIncomeAbove)]
|
||||
pub fn prove_income_above(&self, threshold_cents: u64) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_income_above(threshold_cents)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Prove: income ≥ multiplier × rent
|
||||
///
|
||||
/// Common use: prove income ≥ 3× rent for apartment application
|
||||
#[wasm_bindgen(js_name = proveAffordability)]
|
||||
pub fn prove_affordability(&self, rent_cents: u64, multiplier: u64) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_affordability(rent_cents, multiplier)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Prove: no overdrafts in the past N days
|
||||
#[wasm_bindgen(js_name = proveNoOverdrafts)]
|
||||
pub fn prove_no_overdrafts(&self, days: usize) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_no_overdrafts(days)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Prove: current savings ≥ threshold
|
||||
#[wasm_bindgen(js_name = proveSavingsAbove)]
|
||||
pub fn prove_savings_above(&self, threshold_cents: u64) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_savings_above(threshold_cents)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Prove: average spending in category ≤ budget
|
||||
#[wasm_bindgen(js_name = proveBudgetCompliance)]
|
||||
pub fn prove_budget_compliance(&self, category: &str, budget_cents: u64) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_budget_compliance(category, budget_cents)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Prove: debt-to-income ratio ≤ max_ratio%
|
||||
#[wasm_bindgen(js_name = proveDebtRatio)]
|
||||
pub fn prove_debt_ratio(&self, monthly_debt_cents: u64, max_ratio_percent: u64) -> Result<JsValue, JsValue> {
|
||||
self.builder.prove_debt_ratio(monthly_debt_cents, max_ratio_percent)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Composite Proofs
|
||||
// ========================================================================
|
||||
|
||||
/// Generate complete rental application proof bundle
|
||||
///
|
||||
/// Includes: income proof, stability proof, optional savings proof
|
||||
#[wasm_bindgen(js_name = createRentalApplication)]
|
||||
pub fn create_rental_application(
|
||||
&self,
|
||||
rent_cents: u64,
|
||||
income_multiplier: u64,
|
||||
stability_days: usize,
|
||||
savings_months: Option<u64>,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
RentalApplicationProof::create(
|
||||
&self.builder,
|
||||
rent_cents,
|
||||
income_multiplier,
|
||||
stability_days,
|
||||
savings_months,
|
||||
)
|
||||
.map(|proof| serde_wasm_bindgen::to_value(&proof).unwrap())
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ZkFinancialProver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// WASM-compatible ZK Proof Verifier
|
||||
///
|
||||
/// Can verify proofs without knowing the private values
|
||||
#[wasm_bindgen]
|
||||
pub struct ZkProofVerifier;
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ZkProofVerifier {
|
||||
/// Verify a single ZK proof
|
||||
///
|
||||
/// Returns verification result with validity and statement
|
||||
#[wasm_bindgen]
|
||||
pub fn verify(proof_json: &str) -> Result<JsValue, JsValue> {
|
||||
let proof: ZkProof = serde_json::from_str(proof_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid proof: {}", e)))?;
|
||||
|
||||
let result = RangeProof::verify(&proof);
|
||||
|
||||
serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Verify a rental application proof bundle
|
||||
#[wasm_bindgen(js_name = verifyRentalApplication)]
|
||||
pub fn verify_rental_application(application_json: &str) -> Result<JsValue, JsValue> {
|
||||
let application: RentalApplicationProof = serde_json::from_str(application_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid application: {}", e)))?;
|
||||
|
||||
let results = application.verify();
|
||||
let is_valid = application.is_valid();
|
||||
|
||||
let summary = VerificationSummary {
|
||||
all_valid: is_valid,
|
||||
results,
|
||||
};
|
||||
|
||||
serde_wasm_bindgen::to_value(&summary)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Get human-readable statement from proof
|
||||
#[wasm_bindgen(js_name = getStatement)]
|
||||
pub fn get_statement(proof_json: &str) -> Result<String, JsValue> {
|
||||
let proof: ZkProof = serde_json::from_str(proof_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid proof: {}", e)))?;
|
||||
|
||||
Ok(proof.public_inputs.statement)
|
||||
}
|
||||
|
||||
/// Check if proof is expired
|
||||
#[wasm_bindgen(js_name = isExpired)]
|
||||
pub fn is_expired(proof_json: &str) -> Result<bool, JsValue> {
|
||||
let proof: ZkProof = serde_json::from_str(proof_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid proof: {}", e)))?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(proof.expires_at.map(|exp| now > exp).unwrap_or(false))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct VerificationSummary {
|
||||
all_valid: bool,
|
||||
results: Vec<VerificationResult>,
|
||||
}
|
||||
|
||||
/// Utility functions for ZK proofs
|
||||
#[wasm_bindgen]
|
||||
pub struct ZkUtils;
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ZkUtils {
|
||||
/// Convert dollars to cents (proof system uses cents for precision)
|
||||
#[wasm_bindgen(js_name = dollarsToCents)]
|
||||
pub fn dollars_to_cents(dollars: f64) -> u64 {
|
||||
(dollars * 100.0).round() as u64
|
||||
}
|
||||
|
||||
/// Convert cents to dollars
|
||||
#[wasm_bindgen(js_name = centsToDollars)]
|
||||
pub fn cents_to_dollars(cents: u64) -> f64 {
|
||||
cents as f64 / 100.0
|
||||
}
|
||||
|
||||
/// Generate a shareable proof URL (base64 encoded)
|
||||
#[wasm_bindgen(js_name = proofToUrl)]
|
||||
pub fn proof_to_url(proof_json: &str, base_url: &str) -> String {
|
||||
let encoded = base64_encode(proof_json.as_bytes());
|
||||
format!("{}?proof={}", base_url, encoded)
|
||||
}
|
||||
|
||||
/// Extract proof from URL parameter
|
||||
#[wasm_bindgen(js_name = proofFromUrl)]
|
||||
pub fn proof_from_url(encoded: &str) -> Result<String, JsValue> {
|
||||
let decoded = base64_decode(encoded)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid encoding: {}", e)))?;
|
||||
|
||||
String::from_utf8(decoded)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid UTF-8: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
// Simple base64 encoding (no external deps)
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
let mut result = String::new();
|
||||
|
||||
for chunk in data.chunks(3) {
|
||||
let mut n = (chunk[0] as u32) << 16;
|
||||
if chunk.len() > 1 {
|
||||
n |= (chunk[1] as u32) << 8;
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
n |= chunk[2] as u32;
|
||||
}
|
||||
|
||||
result.push(ALPHABET[(n >> 18) as usize & 0x3F] as char);
|
||||
result.push(ALPHABET[(n >> 12) as usize & 0x3F] as char);
|
||||
|
||||
if chunk.len() > 1 {
|
||||
result.push(ALPHABET[(n >> 6) as usize & 0x3F] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
|
||||
if chunk.len() > 2 {
|
||||
result.push(ALPHABET[n as usize & 0x3F] as char);
|
||||
} else {
|
||||
result.push('=');
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn base64_decode(data: &str) -> Result<Vec<u8>, &'static str> {
|
||||
const DECODE: [i8; 128] = [
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
|
||||
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
|
||||
52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1,
|
||||
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
|
||||
15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
|
||||
-1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
|
||||
41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
let bytes: Vec<u8> = data.bytes().filter(|&b| b != b'=').collect();
|
||||
|
||||
for chunk in bytes.chunks(4) {
|
||||
if chunk.len() < 2 {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut n = 0u32;
|
||||
for (i, &b) in chunk.iter().enumerate() {
|
||||
if b >= 128 || DECODE[b as usize] < 0 {
|
||||
return Err("Invalid base64 character");
|
||||
}
|
||||
n |= (DECODE[b as usize] as u32) << (18 - i * 6);
|
||||
}
|
||||
|
||||
result.push((n >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
result.push((n >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
result.push(n as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
390
vendor/ruvector/examples/edge/src/plaid/zk_wasm_prod.rs
vendored
Normal file
390
vendor/ruvector/examples/edge/src/plaid/zk_wasm_prod.rs
vendored
Normal file
@@ -0,0 +1,390 @@
|
||||
//! Production WASM Bindings for Zero-Knowledge Financial Proofs
|
||||
//!
|
||||
//! Exposes production-grade Bulletproofs to JavaScript with a safe API.
|
||||
//!
|
||||
//! ## Security
|
||||
//!
|
||||
//! - All cryptographic operations use audited libraries
|
||||
//! - Constant-time operations prevent timing attacks
|
||||
//! - No sensitive data exposed to JavaScript
|
||||
|
||||
#![cfg(feature = "wasm")]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::zkproofs_prod::{
|
||||
FinancialProver, FinancialVerifier, ZkRangeProof,
|
||||
RentalApplicationBundle, VerificationResult,
|
||||
};
|
||||
|
||||
/// Production ZK Financial Prover for browser use
|
||||
///
|
||||
/// Uses real Bulletproofs for cryptographically secure range proofs.
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmFinancialProver {
|
||||
inner: FinancialProver,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmFinancialProver {
|
||||
/// Create a new prover
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: FinancialProver::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set monthly income data (in cents)
|
||||
///
|
||||
/// Example: $6,500/month = 650000 cents
|
||||
#[wasm_bindgen(js_name = setIncome)]
|
||||
pub fn set_income(&mut self, income_json: &str) -> Result<(), JsValue> {
|
||||
let income: Vec<u64> = serde_json::from_str(income_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
self.inner.set_income(income);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set daily balance history (in cents)
|
||||
///
|
||||
/// Negative values represent overdrafts.
|
||||
#[wasm_bindgen(js_name = setBalances)]
|
||||
pub fn set_balances(&mut self, balances_json: &str) -> Result<(), JsValue> {
|
||||
let balances: Vec<i64> = serde_json::from_str(balances_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
self.inner.set_balances(balances);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set expense data for a category (in cents)
|
||||
#[wasm_bindgen(js_name = setExpenses)]
|
||||
pub fn set_expenses(&mut self, category: &str, expenses_json: &str) -> Result<(), JsValue> {
|
||||
let expenses: Vec<u64> = serde_json::from_str(expenses_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
self.inner.set_expenses(category, expenses);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prove: average income >= threshold (in cents)
|
||||
///
|
||||
/// Returns a ZK proof that can be verified without revealing actual income.
|
||||
#[wasm_bindgen(js_name = proveIncomeAbove)]
|
||||
pub fn prove_income_above(&mut self, threshold_cents: u64) -> Result<JsValue, JsValue> {
|
||||
let proof = self.inner.prove_income_above(threshold_cents)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&ProofResult::from_proof(proof))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Prove: income >= multiplier × rent
|
||||
///
|
||||
/// Common requirement: income must be 3x rent.
|
||||
#[wasm_bindgen(js_name = proveAffordability)]
|
||||
pub fn prove_affordability(&mut self, rent_cents: u64, multiplier: u64) -> Result<JsValue, JsValue> {
|
||||
let proof = self.inner.prove_affordability(rent_cents, multiplier)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&ProofResult::from_proof(proof))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Prove: no overdrafts in the past N days
|
||||
#[wasm_bindgen(js_name = proveNoOverdrafts)]
|
||||
pub fn prove_no_overdrafts(&mut self, days: usize) -> Result<JsValue, JsValue> {
|
||||
let proof = self.inner.prove_no_overdrafts(days)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&ProofResult::from_proof(proof))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Prove: current savings >= threshold (in cents)
|
||||
#[wasm_bindgen(js_name = proveSavingsAbove)]
|
||||
pub fn prove_savings_above(&mut self, threshold_cents: u64) -> Result<JsValue, JsValue> {
|
||||
let proof = self.inner.prove_savings_above(threshold_cents)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&ProofResult::from_proof(proof))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Prove: average spending in category <= budget (in cents)
|
||||
#[wasm_bindgen(js_name = proveBudgetCompliance)]
|
||||
pub fn prove_budget_compliance(&mut self, category: &str, budget_cents: u64) -> Result<JsValue, JsValue> {
|
||||
let proof = self.inner.prove_budget_compliance(category, budget_cents)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&ProofResult::from_proof(proof))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Create a complete rental application bundle
|
||||
///
|
||||
/// Combines income, stability, and optional savings proofs.
|
||||
#[wasm_bindgen(js_name = createRentalApplication)]
|
||||
pub fn create_rental_application(
|
||||
&mut self,
|
||||
rent_cents: u64,
|
||||
income_multiplier: u64,
|
||||
stability_days: usize,
|
||||
savings_months: Option<u64>,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
let bundle = RentalApplicationBundle::create(
|
||||
&mut self.inner,
|
||||
rent_cents,
|
||||
income_multiplier,
|
||||
stability_days,
|
||||
savings_months,
|
||||
).map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&BundleResult::from_bundle(bundle))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmFinancialProver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Production ZK Verifier for browser use
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmFinancialVerifier;
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmFinancialVerifier {
|
||||
/// Create a new verifier
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Verify a ZK range proof
|
||||
///
|
||||
/// Returns verification result without learning the private value.
|
||||
#[wasm_bindgen]
|
||||
pub fn verify(&self, proof_json: &str) -> Result<JsValue, JsValue> {
|
||||
let proof_result: ProofResult = serde_json::from_str(proof_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
let proof = proof_result.to_proof()
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
let result = FinancialVerifier::verify(&proof)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&VerificationOutput::from_result(result))
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Verify a rental application bundle
|
||||
#[wasm_bindgen(js_name = verifyBundle)]
|
||||
pub fn verify_bundle(&self, bundle_json: &str) -> Result<JsValue, JsValue> {
|
||||
let bundle_result: BundleResult = serde_json::from_str(bundle_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
|
||||
|
||||
let bundle = bundle_result.to_bundle()
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
let valid = bundle.verify()
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&BundleVerification {
|
||||
valid,
|
||||
application_id: bundle.application_id,
|
||||
created_at: bundle.created_at,
|
||||
})
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmFinancialVerifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON-Serializable Types for JS Interop
|
||||
// ============================================================================
|
||||
|
||||
/// Proof result for JS consumption
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProofResult {
|
||||
/// Base64-encoded proof bytes
|
||||
pub proof_base64: String,
|
||||
/// Commitment point (hex)
|
||||
pub commitment_hex: String,
|
||||
/// Lower bound
|
||||
pub min: u64,
|
||||
/// Upper bound
|
||||
pub max: u64,
|
||||
/// Statement
|
||||
pub statement: String,
|
||||
/// Generated timestamp
|
||||
pub generated_at: u64,
|
||||
/// Expiration timestamp
|
||||
pub expires_at: Option<u64>,
|
||||
/// Proof hash (hex)
|
||||
pub hash_hex: String,
|
||||
}
|
||||
|
||||
impl ProofResult {
|
||||
fn from_proof(proof: ZkRangeProof) -> Self {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
Self {
|
||||
proof_base64: STANDARD.encode(&proof.proof_bytes),
|
||||
commitment_hex: hex::encode(proof.commitment.point),
|
||||
min: proof.min,
|
||||
max: proof.max,
|
||||
statement: proof.statement,
|
||||
generated_at: proof.metadata.generated_at,
|
||||
expires_at: proof.metadata.expires_at,
|
||||
hash_hex: hex::encode(proof.metadata.hash),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_proof(&self) -> Result<ZkRangeProof, String> {
|
||||
use super::zkproofs_prod::{PedersenCommitment, ProofMetadata};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
let proof_bytes = STANDARD.decode(&self.proof_base64)
|
||||
.map_err(|e| format!("Invalid base64: {}", e))?;
|
||||
|
||||
let commitment_bytes: [u8; 32] = hex::decode(&self.commitment_hex)
|
||||
.map_err(|e| format!("Invalid commitment hex: {}", e))?
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid commitment length")?;
|
||||
|
||||
let hash_bytes: [u8; 32] = hex::decode(&self.hash_hex)
|
||||
.map_err(|e| format!("Invalid hash hex: {}", e))?
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid hash length")?;
|
||||
|
||||
Ok(ZkRangeProof {
|
||||
proof_bytes,
|
||||
commitment: PedersenCommitment { point: commitment_bytes },
|
||||
min: self.min,
|
||||
max: self.max,
|
||||
statement: self.statement.clone(),
|
||||
metadata: ProofMetadata {
|
||||
generated_at: self.generated_at,
|
||||
expires_at: self.expires_at,
|
||||
version: 1,
|
||||
hash: hash_bytes,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle result for JS consumption
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleResult {
|
||||
/// Income proof
|
||||
pub income_proof: ProofResult,
|
||||
/// Stability proof
|
||||
pub stability_proof: ProofResult,
|
||||
/// Optional savings proof
|
||||
pub savings_proof: Option<ProofResult>,
|
||||
/// Application ID
|
||||
pub application_id: String,
|
||||
/// Created timestamp
|
||||
pub created_at: u64,
|
||||
/// Bundle hash (hex)
|
||||
pub bundle_hash_hex: String,
|
||||
}
|
||||
|
||||
impl BundleResult {
|
||||
fn from_bundle(bundle: RentalApplicationBundle) -> Self {
|
||||
Self {
|
||||
income_proof: ProofResult::from_proof(bundle.income_proof),
|
||||
stability_proof: ProofResult::from_proof(bundle.stability_proof),
|
||||
savings_proof: bundle.savings_proof.map(ProofResult::from_proof),
|
||||
application_id: bundle.application_id,
|
||||
created_at: bundle.created_at,
|
||||
bundle_hash_hex: hex::encode(bundle.bundle_hash),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_bundle(&self) -> Result<RentalApplicationBundle, String> {
|
||||
let bundle_hash: [u8; 32] = hex::decode(&self.bundle_hash_hex)
|
||||
.map_err(|e| format!("Invalid bundle hash: {}", e))?
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid bundle hash length")?;
|
||||
|
||||
Ok(RentalApplicationBundle {
|
||||
income_proof: self.income_proof.to_proof()?,
|
||||
stability_proof: self.stability_proof.to_proof()?,
|
||||
savings_proof: self.savings_proof.as_ref().map(|p| p.to_proof()).transpose()?,
|
||||
application_id: self.application_id.clone(),
|
||||
created_at: self.created_at,
|
||||
bundle_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verification output for JS consumption
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationOutput {
|
||||
/// Whether the proof is valid
|
||||
pub valid: bool,
|
||||
/// The statement that was verified
|
||||
pub statement: String,
|
||||
/// When verified
|
||||
pub verified_at: u64,
|
||||
/// Error message if invalid
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl VerificationOutput {
|
||||
fn from_result(result: super::zkproofs_prod::VerificationResult) -> Self {
|
||||
Self {
|
||||
valid: result.valid,
|
||||
statement: result.statement,
|
||||
verified_at: result.verified_at,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundle verification result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleVerification {
|
||||
pub valid: bool,
|
||||
pub application_id: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Check if production ZK is available
|
||||
#[wasm_bindgen(js_name = isProductionZkAvailable)]
|
||||
pub fn is_production_zk_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Get ZK library version info
|
||||
#[wasm_bindgen(js_name = getZkVersionInfo)]
|
||||
pub fn get_zk_version_info() -> JsValue {
|
||||
let info = serde_json::json!({
|
||||
"version": "1.0.0",
|
||||
"library": "bulletproofs",
|
||||
"curve": "ristretto255",
|
||||
"transcript": "merlin",
|
||||
"security_level": "128-bit",
|
||||
"features": [
|
||||
"range_proofs",
|
||||
"pedersen_commitments",
|
||||
"constant_time_operations",
|
||||
"fiat_shamir_transform"
|
||||
]
|
||||
});
|
||||
|
||||
serde_wasm_bindgen::to_value(&info).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
712
vendor/ruvector/examples/edge/src/plaid/zkproofs.rs
vendored
Normal file
712
vendor/ruvector/examples/edge/src/plaid/zkproofs.rs
vendored
Normal file
@@ -0,0 +1,712 @@
|
||||
//! Zero-Knowledge Financial Proofs
|
||||
//!
|
||||
//! Prove financial statements without revealing actual numbers.
|
||||
//! All proofs are generated in the browser - private data never leaves.
|
||||
//!
|
||||
//! # ⚠️ SECURITY WARNING ⚠️
|
||||
//!
|
||||
//! **THIS IS A DEMONSTRATION IMPLEMENTATION - NOT PRODUCTION READY**
|
||||
//!
|
||||
//! The cryptographic primitives in this module are SIMPLIFIED for educational
|
||||
//! purposes and API demonstration. They do NOT provide real security:
|
||||
//!
|
||||
//! - Custom hash function (not SHA-256)
|
||||
//! - Simplified Pedersen commitments (not elliptic curve based)
|
||||
//! - Mock bulletproof verification (does not verify mathematical properties)
|
||||
//!
|
||||
//! ## For Production Use
|
||||
//!
|
||||
//! Replace with battle-tested cryptographic libraries:
|
||||
//! ```toml
|
||||
//! bulletproofs = "4.0" # Real bulletproofs
|
||||
//! curve25519-dalek = "4.0" # Elliptic curve operations
|
||||
//! merlin = "3.0" # Fiat-Shamir transcripts
|
||||
//! sha2 = "0.10" # Cryptographic hash
|
||||
//! ```
|
||||
//!
|
||||
//! ## Supported Proofs (API Demo)
|
||||
//!
|
||||
//! - **Range Proofs**: Prove a value is within a range
|
||||
//! - **Comparison Proofs**: Prove value A > value B
|
||||
//! - **Aggregate Proofs**: Prove sum/average meets criteria
|
||||
//! - **History Proofs**: Prove statements about transaction history
|
||||
//!
|
||||
//! ## Cryptographic Basis (Production)
|
||||
//!
|
||||
//! Real implementation would use Bulletproofs for range proofs (no trusted setup).
|
||||
//! Pedersen commitments on Ristretto255 curve hide values while allowing verification.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
|
||||
/// A committed value - hides the actual number
|
||||
///
|
||||
/// # Security Note
|
||||
/// In production, this would be a Ristretto255 point: `C = v·G + r·H`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Commitment {
|
||||
/// The commitment point (in production: compressed Ristretto255)
|
||||
pub point: [u8; 32],
|
||||
// NOTE: Blinding factor removed from struct to prevent accidental leakage.
|
||||
// Prover must track blindings separately in a secure manner.
|
||||
}
|
||||
|
||||
/// A zero-knowledge proof
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZkProof {
|
||||
/// Proof type identifier
|
||||
pub proof_type: ProofType,
|
||||
/// The actual proof bytes
|
||||
pub proof_data: Vec<u8>,
|
||||
/// Public inputs (what the verifier needs)
|
||||
pub public_inputs: PublicInputs,
|
||||
/// Timestamp when proof was generated
|
||||
pub generated_at: u64,
|
||||
/// Expiration (proofs can be time-limited)
|
||||
pub expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Types of proofs we can generate
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ProofType {
|
||||
/// Prove: value ∈ [min, max]
|
||||
Range,
|
||||
/// Prove: value_a > value_b (or ≥, <, ≤)
|
||||
Comparison,
|
||||
/// Prove: income ≥ multiplier × expense
|
||||
Affordability,
|
||||
/// Prove: all values in set ≥ 0 (no overdrafts)
|
||||
NonNegative,
|
||||
/// Prove: sum of values ≤ threshold
|
||||
SumBound,
|
||||
/// Prove: average of values meets criteria
|
||||
AverageBound,
|
||||
/// Prove: membership in a set (e.g., verified accounts)
|
||||
SetMembership,
|
||||
}
|
||||
|
||||
/// Public inputs that verifier sees
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicInputs {
|
||||
/// Commitments to hidden values
|
||||
pub commitments: Vec<Commitment>,
|
||||
/// Public threshold/bound values
|
||||
pub bounds: Vec<u64>,
|
||||
/// Statement being proven (human readable)
|
||||
pub statement: String,
|
||||
/// Optional: institution that signed the source data
|
||||
pub attestation: Option<Attestation>,
|
||||
}
|
||||
|
||||
/// Attestation from a trusted source (e.g., Plaid)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Attestation {
|
||||
/// Who attested (e.g., "plaid.com")
|
||||
pub issuer: String,
|
||||
/// Signature over the commitments
|
||||
pub signature: Vec<u8>,
|
||||
/// When the attestation was made
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Result of proof verification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationResult {
|
||||
pub valid: bool,
|
||||
pub statement: String,
|
||||
pub verified_at: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pedersen Commitments (Simplified)
|
||||
// ============================================================================
|
||||
|
||||
/// Pedersen commitment scheme
|
||||
/// C = v*G + r*H where v=value, r=blinding, G,H=generator points
|
||||
pub struct PedersenCommitment;
|
||||
|
||||
impl PedersenCommitment {
|
||||
/// Create a commitment to a value
|
||||
pub fn commit(value: u64, blinding: &[u8; 32]) -> Commitment {
|
||||
// Simplified: In production, use curve25519-dalek
|
||||
let mut point = [0u8; 32];
|
||||
|
||||
// Hash(value || blinding) as simplified commitment
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&value.to_le_bytes());
|
||||
hasher.update(blinding);
|
||||
let hash = hasher.finalize();
|
||||
point.copy_from_slice(&hash[..32]);
|
||||
|
||||
Commitment {
|
||||
point,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate random blinding factor
|
||||
pub fn random_blinding() -> [u8; 32] {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut blinding = [0u8; 32];
|
||||
rng.fill(&mut blinding);
|
||||
blinding
|
||||
}
|
||||
|
||||
/// Verify a commitment opens to a value (only prover can do this)
|
||||
pub fn verify_opening(commitment: &Commitment, value: u64, blinding: &[u8; 32]) -> bool {
|
||||
let expected = Self::commit(value, blinding);
|
||||
commitment.point == expected.point
|
||||
}
|
||||
}
|
||||
|
||||
// Simple SHA256 for commitments
|
||||
struct Sha256 {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Sha256 {
|
||||
fn new() -> Self {
|
||||
Self { data: Vec::new() }
|
||||
}
|
||||
|
||||
fn update(&mut self, data: &[u8]) {
|
||||
self.data.extend_from_slice(data);
|
||||
}
|
||||
|
||||
fn finalize(self) -> [u8; 32] {
|
||||
// Simplified hash - in production use sha2 crate
|
||||
let mut result = [0u8; 32];
|
||||
for (i, chunk) in self.data.chunks(32).enumerate() {
|
||||
for (j, &byte) in chunk.iter().enumerate() {
|
||||
result[(i + j) % 32] ^= byte.wrapping_mul((i + j + 1) as u8);
|
||||
}
|
||||
}
|
||||
// Mix more
|
||||
for i in 0..32 {
|
||||
result[i] = result[i]
|
||||
.wrapping_add(result[(i + 7) % 32])
|
||||
.wrapping_mul(result[(i + 13) % 32] | 1);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Range Proofs (Bulletproofs-style)
|
||||
// ============================================================================
|
||||
|
||||
/// Bulletproof-style range proof
|
||||
/// Proves: value ∈ [0, 2^n) without revealing value
|
||||
pub struct RangeProof;
|
||||
|
||||
impl RangeProof {
|
||||
/// Generate a range proof
|
||||
/// Proves: committed_value ∈ [min, max]
|
||||
pub fn prove(value: u64, min: u64, max: u64, blinding: &[u8; 32]) -> Result<ZkProof, String> {
|
||||
// Validate range
|
||||
if value < min || value > max {
|
||||
return Err("Value not in range".to_string());
|
||||
}
|
||||
|
||||
// Create commitment
|
||||
let commitment = PedersenCommitment::commit(value, blinding);
|
||||
|
||||
// Generate proof data (simplified Bulletproof)
|
||||
// In production: use bulletproofs crate
|
||||
let proof_data = Self::generate_bulletproof(value, min, max, blinding);
|
||||
|
||||
Ok(ZkProof {
|
||||
proof_type: ProofType::Range,
|
||||
proof_data,
|
||||
public_inputs: PublicInputs {
|
||||
commitments: vec![commitment],
|
||||
bounds: vec![min, max],
|
||||
statement: format!("Value is between {} and {}", min, max),
|
||||
attestation: None,
|
||||
},
|
||||
generated_at: current_timestamp(),
|
||||
expires_at: Some(current_timestamp() + 86400 * 30), // 30 days
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a range proof
|
||||
pub fn verify(proof: &ZkProof) -> VerificationResult {
|
||||
if proof.proof_type != ProofType::Range {
|
||||
return VerificationResult {
|
||||
valid: false,
|
||||
statement: proof.public_inputs.statement.clone(),
|
||||
verified_at: current_timestamp(),
|
||||
error: Some("Wrong proof type".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the bulletproof (simplified)
|
||||
let valid = Self::verify_bulletproof(
|
||||
&proof.proof_data,
|
||||
&proof.public_inputs.commitments[0],
|
||||
proof.public_inputs.bounds[0],
|
||||
proof.public_inputs.bounds[1],
|
||||
);
|
||||
|
||||
VerificationResult {
|
||||
valid,
|
||||
statement: proof.public_inputs.statement.clone(),
|
||||
verified_at: current_timestamp(),
|
||||
error: if valid { None } else { Some("Proof verification failed".to_string()) },
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified bulletproof generation
|
||||
fn generate_bulletproof(value: u64, min: u64, max: u64, blinding: &[u8; 32]) -> Vec<u8> {
|
||||
let mut proof = Vec::new();
|
||||
|
||||
// Encode shifted value (value - min)
|
||||
let shifted = value - min;
|
||||
let range = max - min;
|
||||
|
||||
// Number of bits needed
|
||||
let bits = (64 - range.leading_zeros()) as usize;
|
||||
|
||||
// Generate bit commitments (simplified)
|
||||
for i in 0..bits {
|
||||
let bit = (shifted >> i) & 1;
|
||||
let bit_blinding = Self::derive_bit_blinding(blinding, i);
|
||||
let bit_commitment = PedersenCommitment::commit(bit, &bit_blinding);
|
||||
proof.extend_from_slice(&bit_commitment.point);
|
||||
}
|
||||
|
||||
// Add challenge response (Fiat-Shamir)
|
||||
let challenge = Self::fiat_shamir_challenge(&proof, blinding);
|
||||
proof.extend_from_slice(&challenge);
|
||||
|
||||
proof
|
||||
}
|
||||
|
||||
// Simplified bulletproof verification
|
||||
fn verify_bulletproof(
|
||||
proof_data: &[u8],
|
||||
commitment: &Commitment,
|
||||
min: u64,
|
||||
max: u64,
|
||||
) -> bool {
|
||||
let range = max - min;
|
||||
let bits = (64 - range.leading_zeros()) as usize;
|
||||
|
||||
// Check proof has correct structure
|
||||
let expected_len = bits * 32 + 32; // bit commitments + challenge
|
||||
if proof_data.len() != expected_len {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify structure (simplified - real bulletproofs do much more)
|
||||
// In production: verify inner product argument
|
||||
|
||||
// Check challenge is properly formed
|
||||
let challenge_start = bits * 32;
|
||||
let _challenge = &proof_data[challenge_start..];
|
||||
|
||||
// Simplified: just check it's not all zeros
|
||||
proof_data.iter().any(|&b| b != 0)
|
||||
}
|
||||
|
||||
fn derive_bit_blinding(base_blinding: &[u8; 32], bit_index: usize) -> [u8; 32] {
|
||||
let mut result = *base_blinding;
|
||||
result[0] ^= bit_index as u8;
|
||||
result[31] ^= (bit_index >> 8) as u8;
|
||||
result
|
||||
}
|
||||
|
||||
fn fiat_shamir_challenge(transcript: &[u8], blinding: &[u8; 32]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(transcript);
|
||||
hasher.update(blinding);
|
||||
hasher.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Financial Proof Builder
|
||||
// ============================================================================
|
||||
|
||||
/// Builder for common financial proofs
|
||||
pub struct FinancialProofBuilder {
|
||||
/// Monthly income values
|
||||
income: Vec<u64>,
|
||||
/// Monthly expenses by category
|
||||
expenses: HashMap<String, Vec<u64>>,
|
||||
/// Account balances over time
|
||||
balances: Vec<i64>,
|
||||
/// Blinding factors (kept secret)
|
||||
blindings: HashMap<String, [u8; 32]>,
|
||||
}
|
||||
|
||||
impl FinancialProofBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
income: Vec::new(),
|
||||
expenses: HashMap::new(),
|
||||
balances: Vec::new(),
|
||||
blindings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add monthly income data
|
||||
pub fn with_income(mut self, monthly_income: Vec<u64>) -> Self {
|
||||
self.income = monthly_income;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add expense category data
|
||||
pub fn with_expenses(mut self, category: &str, monthly: Vec<u64>) -> Self {
|
||||
self.expenses.insert(category.to_string(), monthly);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add balance history
|
||||
pub fn with_balances(mut self, daily_balances: Vec<i64>) -> Self {
|
||||
self.balances = daily_balances;
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Proof Generation
|
||||
// ========================================================================
|
||||
|
||||
/// Prove: income ≥ threshold
|
||||
pub fn prove_income_above(&self, threshold: u64) -> Result<ZkProof, String> {
|
||||
let avg_income = self.income.iter().sum::<u64>() / self.income.len().max(1) as u64;
|
||||
|
||||
let blinding = self.get_or_create_blinding("income");
|
||||
RangeProof::prove(avg_income, threshold, u64::MAX / 2, &blinding)
|
||||
.map(|mut p| {
|
||||
p.public_inputs.statement = format!(
|
||||
"Average monthly income ≥ ${}",
|
||||
threshold
|
||||
);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Prove: income ≥ multiplier × rent (affordability)
|
||||
pub fn prove_affordability(&self, rent: u64, multiplier: u64) -> Result<ZkProof, String> {
|
||||
let avg_income = self.income.iter().sum::<u64>() / self.income.len().max(1) as u64;
|
||||
let required = rent * multiplier;
|
||||
|
||||
if avg_income < required {
|
||||
return Err("Income does not meet affordability requirement".to_string());
|
||||
}
|
||||
|
||||
let blinding = self.get_or_create_blinding("affordability");
|
||||
|
||||
// Prove income ≥ required
|
||||
RangeProof::prove(avg_income, required, u64::MAX / 2, &blinding)
|
||||
.map(|mut p| {
|
||||
p.proof_type = ProofType::Affordability;
|
||||
p.public_inputs.statement = format!(
|
||||
"Income ≥ {}× monthly rent of ${}",
|
||||
multiplier, rent
|
||||
);
|
||||
p.public_inputs.bounds = vec![rent, multiplier];
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Prove: no overdrafts (all balances ≥ 0) for N days
|
||||
pub fn prove_no_overdrafts(&self, days: usize) -> Result<ZkProof, String> {
|
||||
let relevant_balances = if days < self.balances.len() {
|
||||
&self.balances[self.balances.len() - days..]
|
||||
} else {
|
||||
&self.balances[..]
|
||||
};
|
||||
|
||||
// Check all balances are non-negative
|
||||
let min_balance = *relevant_balances.iter().min().unwrap_or(&0);
|
||||
if min_balance < 0 {
|
||||
return Err("Overdraft detected in period".to_string());
|
||||
}
|
||||
|
||||
let blinding = self.get_or_create_blinding("no_overdraft");
|
||||
|
||||
// Prove minimum balance ≥ 0
|
||||
RangeProof::prove(min_balance as u64, 0, u64::MAX / 2, &blinding)
|
||||
.map(|mut p| {
|
||||
p.proof_type = ProofType::NonNegative;
|
||||
p.public_inputs.statement = format!(
|
||||
"No overdrafts in the past {} days",
|
||||
days
|
||||
);
|
||||
p.public_inputs.bounds = vec![days as u64, 0];
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Prove: savings ≥ threshold
|
||||
pub fn prove_savings_above(&self, threshold: u64) -> Result<ZkProof, String> {
|
||||
let current_balance = *self.balances.last().unwrap_or(&0);
|
||||
|
||||
if current_balance < threshold as i64 {
|
||||
return Err("Savings below threshold".to_string());
|
||||
}
|
||||
|
||||
let blinding = self.get_or_create_blinding("savings");
|
||||
|
||||
RangeProof::prove(current_balance as u64, threshold, u64::MAX / 2, &blinding)
|
||||
.map(|mut p| {
|
||||
p.public_inputs.statement = format!(
|
||||
"Current savings ≥ ${}",
|
||||
threshold
|
||||
);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Prove: average spending in category ≤ budget
|
||||
pub fn prove_budget_compliance(
|
||||
&self,
|
||||
category: &str,
|
||||
budget: u64,
|
||||
) -> Result<ZkProof, String> {
|
||||
let expenses = self.expenses.get(category)
|
||||
.ok_or_else(|| format!("No data for category: {}", category))?;
|
||||
|
||||
let avg_spending = expenses.iter().sum::<u64>() / expenses.len().max(1) as u64;
|
||||
|
||||
if avg_spending > budget {
|
||||
return Err("Average spending exceeds budget".to_string());
|
||||
}
|
||||
|
||||
let blinding = self.get_or_create_blinding(&format!("budget_{}", category));
|
||||
|
||||
// Prove spending ≤ budget (equivalent to: spending ∈ [0, budget])
|
||||
RangeProof::prove(avg_spending, 0, budget, &blinding)
|
||||
.map(|mut p| {
|
||||
p.proof_type = ProofType::SumBound;
|
||||
p.public_inputs.statement = format!(
|
||||
"Average {} spending ≤ ${}/month",
|
||||
category, budget
|
||||
);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Prove: debt-to-income ratio ≤ threshold%
|
||||
pub fn prove_debt_ratio(&self, monthly_debt: u64, max_ratio: u64) -> Result<ZkProof, String> {
|
||||
let avg_income = self.income.iter().sum::<u64>() / self.income.len().max(1) as u64;
|
||||
|
||||
// ratio = (debt * 100) / income
|
||||
let actual_ratio = (monthly_debt * 100) / avg_income.max(1);
|
||||
|
||||
if actual_ratio > max_ratio {
|
||||
return Err("Debt ratio exceeds maximum".to_string());
|
||||
}
|
||||
|
||||
let blinding = self.get_or_create_blinding("debt_ratio");
|
||||
|
||||
RangeProof::prove(actual_ratio, 0, max_ratio, &blinding)
|
||||
.map(|mut p| {
|
||||
p.public_inputs.statement = format!(
|
||||
"Debt-to-income ratio ≤ {}%",
|
||||
max_ratio
|
||||
);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
fn get_or_create_blinding(&self, key: &str) -> [u8; 32] {
|
||||
// In real impl, would store and reuse blindings
|
||||
// For now, generate deterministically from key
|
||||
let mut blinding = [0u8; 32];
|
||||
for (i, c) in key.bytes().enumerate() {
|
||||
blinding[i % 32] ^= c;
|
||||
}
|
||||
// Add randomness
|
||||
let random = PedersenCommitment::random_blinding();
|
||||
for i in 0..32 {
|
||||
blinding[i] ^= random[i];
|
||||
}
|
||||
blinding
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FinancialProofBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composite Proofs (Multiple Statements)
|
||||
// ============================================================================
|
||||
|
||||
/// A bundle of proofs for rental application
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RentalApplicationProof {
|
||||
/// Prove income meets requirement
|
||||
pub income_proof: ZkProof,
|
||||
/// Prove no overdrafts
|
||||
pub stability_proof: ZkProof,
|
||||
/// Prove savings buffer
|
||||
pub savings_proof: Option<ZkProof>,
|
||||
/// Application metadata
|
||||
pub metadata: ApplicationMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApplicationMetadata {
|
||||
pub applicant_id: String,
|
||||
pub property_id: Option<String>,
|
||||
pub generated_at: u64,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
impl RentalApplicationProof {
|
||||
/// Create a complete rental application proof bundle
|
||||
pub fn create(
|
||||
builder: &FinancialProofBuilder,
|
||||
rent: u64,
|
||||
income_multiplier: u64,
|
||||
stability_days: usize,
|
||||
savings_months: Option<u64>,
|
||||
) -> Result<Self, String> {
|
||||
let income_proof = builder.prove_affordability(rent, income_multiplier)?;
|
||||
let stability_proof = builder.prove_no_overdrafts(stability_days)?;
|
||||
|
||||
let savings_proof = if let Some(months) = savings_months {
|
||||
Some(builder.prove_savings_above(rent * months)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
income_proof,
|
||||
stability_proof,
|
||||
savings_proof,
|
||||
metadata: ApplicationMetadata {
|
||||
applicant_id: generate_anonymous_id(),
|
||||
property_id: None,
|
||||
generated_at: current_timestamp(),
|
||||
expires_at: current_timestamp() + 86400 * 30, // 30 days
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify all proofs in the bundle
|
||||
pub fn verify(&self) -> Vec<VerificationResult> {
|
||||
let mut results = vec![
|
||||
RangeProof::verify(&self.income_proof),
|
||||
RangeProof::verify(&self.stability_proof),
|
||||
];
|
||||
|
||||
if let Some(ref savings_proof) = self.savings_proof {
|
||||
results.push(RangeProof::verify(savings_proof));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Check if application is valid (all proofs pass)
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.verify().iter().all(|r| r.valid)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn generate_anonymous_id() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut bytes = [0u8; 16];
|
||||
rng.fill(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_range_proof() {
|
||||
let value = 5000u64;
|
||||
let blinding = PedersenCommitment::random_blinding();
|
||||
|
||||
let proof = RangeProof::prove(value, 3000, 10000, &blinding).unwrap();
|
||||
let result = RangeProof::verify(&proof);
|
||||
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_income_proof() {
|
||||
let builder = FinancialProofBuilder::new()
|
||||
.with_income(vec![6500, 6500, 6800, 6500]); // ~$6500/month
|
||||
|
||||
// Prove income ≥ $5000
|
||||
let proof = builder.prove_income_above(5000).unwrap();
|
||||
let result = RangeProof::verify(&proof);
|
||||
|
||||
assert!(result.valid);
|
||||
assert!(result.statement.contains("5000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_affordability_proof() {
|
||||
let builder = FinancialProofBuilder::new()
|
||||
.with_income(vec![6500, 6500, 6500, 6500]);
|
||||
|
||||
// Prove can afford $2000 rent (need 3x = $6000)
|
||||
let proof = builder.prove_affordability(2000, 3).unwrap();
|
||||
let result = RangeProof::verify(&proof);
|
||||
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_overdraft_proof() {
|
||||
let builder = FinancialProofBuilder::new()
|
||||
.with_balances(vec![1000, 800, 1200, 500, 900, 1100, 1500]);
|
||||
|
||||
let proof = builder.prove_no_overdrafts(7).unwrap();
|
||||
let result = RangeProof::verify(&proof);
|
||||
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rental_application() {
|
||||
let builder = FinancialProofBuilder::new()
|
||||
.with_income(vec![6500, 6500, 6500, 6500])
|
||||
.with_balances(vec![5000, 5200, 4800, 5100, 5300, 5000, 5500]);
|
||||
|
||||
let application = RentalApplicationProof::create(
|
||||
&builder,
|
||||
2000, // rent
|
||||
3, // income multiplier
|
||||
30, // stability days
|
||||
Some(2), // 2 months savings
|
||||
).unwrap();
|
||||
|
||||
assert!(application.is_valid());
|
||||
}
|
||||
}
|
||||
800
vendor/ruvector/examples/edge/src/plaid/zkproofs_prod.rs
vendored
Normal file
800
vendor/ruvector/examples/edge/src/plaid/zkproofs_prod.rs
vendored
Normal file
@@ -0,0 +1,800 @@
|
||||
//! Production-Ready Zero-Knowledge Financial Proofs
|
||||
//!
|
||||
//! This module provides cryptographically secure zero-knowledge proofs using:
|
||||
//! - **Bulletproofs** for range proofs (no trusted setup)
|
||||
//! - **Ristretto255** for Pedersen commitments (constant-time, safe API)
|
||||
//! - **Merlin** for Fiat-Shamir transcripts
|
||||
//! - **SHA-512** for secure hashing
|
||||
//!
|
||||
//! ## Security Properties
|
||||
//!
|
||||
//! - **Zero-Knowledge**: Verifier learns nothing beyond validity
|
||||
//! - **Soundness**: Computationally infeasible to create false proofs
|
||||
//! - **Completeness**: Valid statements always produce valid proofs
|
||||
//! - **Side-channel resistant**: Constant-time operations throughout
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use ruvector_edge::plaid::zkproofs_prod::*;
|
||||
//!
|
||||
//! // Create prover with private data
|
||||
//! let mut prover = FinancialProver::new();
|
||||
//! prover.set_income(vec![650000, 650000, 680000]); // cents
|
||||
//!
|
||||
//! // Generate proof (income >= 3x rent)
|
||||
//! let proof = prover.prove_affordability(200000, 3)?; // $2000 rent
|
||||
//!
|
||||
//! // Verify (learns nothing about actual income)
|
||||
//! let valid = FinancialVerifier::verify(&proof)?;
|
||||
//! assert!(valid);
|
||||
//! ```
|
||||
|
||||
use bulletproofs::{BulletproofGens, PedersenGens, RangeProof as BulletproofRangeProof};
|
||||
use curve25519_dalek::{ristretto::CompressedRistretto, scalar::Scalar};
|
||||
use merlin::Transcript;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha512};
|
||||
use std::collections::HashMap;
|
||||
use subtle::ConstantTimeEq;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Domain separator for financial proof transcripts
|
||||
const TRANSCRIPT_LABEL: &[u8] = b"ruvector-financial-zk-v1";
|
||||
|
||||
/// Maximum bit size for range proofs (64-bit values)
|
||||
const MAX_BITS: usize = 64;
|
||||
|
||||
// Pre-computed generators - optimized for single-party proofs (not aggregation)
|
||||
lazy_static::lazy_static! {
|
||||
static ref BP_GENS: BulletproofGens = BulletproofGens::new(MAX_BITS, 1); // 1-party saves 8MB
|
||||
static ref PC_GENS: PedersenGens = PedersenGens::default();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
|
||||
/// A Pedersen commitment to a hidden value
|
||||
///
|
||||
/// Commitment = value·G + blinding·H where G, H are Ristretto255 points
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PedersenCommitment {
|
||||
/// Compressed Ristretto255 point (32 bytes)
|
||||
pub point: [u8; 32],
|
||||
}
|
||||
|
||||
impl PedersenCommitment {
|
||||
/// Create a commitment to a value with random blinding
|
||||
pub fn commit(value: u64) -> (Self, Scalar) {
|
||||
let blinding = Scalar::random(&mut OsRng);
|
||||
let commitment = PC_GENS.commit(Scalar::from(value), blinding);
|
||||
|
||||
(
|
||||
Self {
|
||||
point: commitment.compress().to_bytes(),
|
||||
},
|
||||
blinding,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a commitment with specified blinding factor
|
||||
pub fn commit_with_blinding(value: u64, blinding: &Scalar) -> Self {
|
||||
let commitment = PC_GENS.commit(Scalar::from(value), *blinding);
|
||||
Self {
|
||||
point: commitment.compress().to_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompress to Ristretto point
|
||||
pub fn decompress(&self) -> Option<curve25519_dalek::ristretto::RistrettoPoint> {
|
||||
CompressedRistretto::from_slice(&self.point)
|
||||
.ok()?
|
||||
.decompress()
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-knowledge range proof
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZkRangeProof {
|
||||
/// The cryptographic proof bytes
|
||||
pub proof_bytes: Vec<u8>,
|
||||
/// Commitment to the value being proved
|
||||
pub commitment: PedersenCommitment,
|
||||
/// Lower bound (public)
|
||||
pub min: u64,
|
||||
/// Upper bound (public)
|
||||
pub max: u64,
|
||||
/// Human-readable statement
|
||||
pub statement: String,
|
||||
/// Proof metadata
|
||||
pub metadata: ProofMetadata,
|
||||
}
|
||||
|
||||
/// Proof metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProofMetadata {
|
||||
/// When the proof was generated (Unix timestamp)
|
||||
pub generated_at: u64,
|
||||
/// When the proof expires (optional)
|
||||
pub expires_at: Option<u64>,
|
||||
/// Proof version for compatibility
|
||||
pub version: u8,
|
||||
/// Hash of the proof for integrity
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl ProofMetadata {
|
||||
fn new(proof_bytes: &[u8], expires_in_days: Option<u64>) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(proof_bytes);
|
||||
let hash_result = hasher.finalize();
|
||||
let mut hash = [0u8; 32];
|
||||
hash.copy_from_slice(&hash_result[..32]);
|
||||
|
||||
Self {
|
||||
generated_at: now,
|
||||
expires_at: expires_in_days.map(|d| now + d * 86400),
|
||||
version: 1,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if proof is expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
if let Some(expires) = self.expires_at {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
now > expires
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verification result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationResult {
|
||||
/// Whether the proof is valid
|
||||
pub valid: bool,
|
||||
/// The statement that was verified
|
||||
pub statement: String,
|
||||
/// When verification occurred
|
||||
pub verified_at: u64,
|
||||
/// Any error message
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Financial Prover
|
||||
// ============================================================================
|
||||
|
||||
/// Prover for financial statements
|
||||
///
|
||||
/// Stores private financial data and generates ZK proofs.
|
||||
/// Blinding factors are automatically zeroized on drop for security.
|
||||
pub struct FinancialProver {
|
||||
/// Monthly income values (in cents)
|
||||
income: Vec<u64>,
|
||||
/// Daily balance history (in cents, can be negative represented as i64 then converted)
|
||||
balances: Vec<i64>,
|
||||
/// Monthly expenses by category
|
||||
expenses: HashMap<String, Vec<u64>>,
|
||||
/// Blinding factors for commitments (to allow proof combination)
|
||||
/// SECURITY: These are sensitive - zeroized on drop
|
||||
blindings: HashMap<String, Scalar>,
|
||||
}
|
||||
|
||||
impl Drop for FinancialProver {
|
||||
fn drop(&mut self) {
|
||||
// Zeroize sensitive data on drop to prevent memory extraction attacks
|
||||
// Note: Scalar internally uses [u8; 32] which we can't directly zeroize,
|
||||
// but clearing the HashMap removes references
|
||||
self.blindings.clear();
|
||||
self.income.zeroize();
|
||||
self.balances.zeroize();
|
||||
// Zeroize expense values
|
||||
for expenses in self.expenses.values_mut() {
|
||||
expenses.zeroize();
|
||||
}
|
||||
self.expenses.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl FinancialProver {
|
||||
/// Create a new prover
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
income: Vec::new(),
|
||||
balances: Vec::new(),
|
||||
expenses: HashMap::new(),
|
||||
blindings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set monthly income data
|
||||
pub fn set_income(&mut self, monthly_income: Vec<u64>) {
|
||||
self.income = monthly_income;
|
||||
}
|
||||
|
||||
/// Set daily balance history
|
||||
pub fn set_balances(&mut self, daily_balances: Vec<i64>) {
|
||||
self.balances = daily_balances;
|
||||
}
|
||||
|
||||
/// Set expense data for a category
|
||||
pub fn set_expenses(&mut self, category: &str, monthly_expenses: Vec<u64>) {
|
||||
self.expenses.insert(category.to_string(), monthly_expenses);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Proof Generation
|
||||
// ========================================================================
|
||||
|
||||
/// Prove: average income >= threshold
|
||||
pub fn prove_income_above(&mut self, threshold: u64) -> Result<ZkRangeProof, String> {
|
||||
if self.income.is_empty() {
|
||||
return Err("No income data provided".to_string());
|
||||
}
|
||||
|
||||
let avg_income = self.income.iter().sum::<u64>() / self.income.len() as u64;
|
||||
|
||||
if avg_income < threshold {
|
||||
return Err("Income does not meet threshold".to_string());
|
||||
}
|
||||
|
||||
// Prove: avg_income - threshold >= 0 (i.e., avg_income is in range [threshold, max])
|
||||
self.create_range_proof(
|
||||
avg_income,
|
||||
threshold,
|
||||
u64::MAX / 2,
|
||||
format!("Average monthly income >= ${:.2}", threshold as f64 / 100.0),
|
||||
"income",
|
||||
)
|
||||
}
|
||||
|
||||
/// Prove: income >= multiplier × rent (affordability)
|
||||
pub fn prove_affordability(&mut self, rent: u64, multiplier: u64) -> Result<ZkRangeProof, String> {
|
||||
// Input validation to prevent trivial proof bypass
|
||||
if rent == 0 {
|
||||
return Err("Rent must be greater than zero".to_string());
|
||||
}
|
||||
if multiplier == 0 || multiplier > 100 {
|
||||
return Err("Multiplier must be between 1 and 100".to_string());
|
||||
}
|
||||
if self.income.is_empty() {
|
||||
return Err("No income data provided".to_string());
|
||||
}
|
||||
|
||||
let avg_income = self.income.iter().sum::<u64>() / self.income.len() as u64;
|
||||
let required = rent.checked_mul(multiplier)
|
||||
.ok_or("Rent × multiplier overflow")?;
|
||||
|
||||
if avg_income < required {
|
||||
return Err(format!(
|
||||
"Income ${:.2} does not meet {}x rent requirement ${:.2}",
|
||||
avg_income as f64 / 100.0,
|
||||
multiplier,
|
||||
required as f64 / 100.0
|
||||
));
|
||||
}
|
||||
|
||||
self.create_range_proof(
|
||||
avg_income,
|
||||
required,
|
||||
u64::MAX / 2,
|
||||
format!(
|
||||
"Income >= {}× monthly rent of ${:.2}",
|
||||
multiplier,
|
||||
rent as f64 / 100.0
|
||||
),
|
||||
"affordability",
|
||||
)
|
||||
}
|
||||
|
||||
/// Prove: minimum balance >= 0 for last N days (no overdrafts)
|
||||
pub fn prove_no_overdrafts(&mut self, days: usize) -> Result<ZkRangeProof, String> {
|
||||
if self.balances.is_empty() {
|
||||
return Err("No balance data provided".to_string());
|
||||
}
|
||||
|
||||
let relevant = if days < self.balances.len() {
|
||||
&self.balances[self.balances.len() - days..]
|
||||
} else {
|
||||
&self.balances[..]
|
||||
};
|
||||
|
||||
let min_balance = *relevant.iter().min().unwrap_or(&0);
|
||||
|
||||
if min_balance < 0 {
|
||||
return Err("Overdraft detected in the specified period".to_string());
|
||||
}
|
||||
|
||||
// Prove minimum balance is non-negative
|
||||
self.create_range_proof(
|
||||
min_balance as u64,
|
||||
0,
|
||||
u64::MAX / 2,
|
||||
format!("No overdrafts in the past {} days", days),
|
||||
"no_overdraft",
|
||||
)
|
||||
}
|
||||
|
||||
/// Prove: current savings >= threshold
|
||||
pub fn prove_savings_above(&mut self, threshold: u64) -> Result<ZkRangeProof, String> {
|
||||
if self.balances.is_empty() {
|
||||
return Err("No balance data provided".to_string());
|
||||
}
|
||||
|
||||
let current = *self.balances.last().unwrap_or(&0);
|
||||
|
||||
if current < threshold as i64 {
|
||||
return Err("Savings do not meet threshold".to_string());
|
||||
}
|
||||
|
||||
self.create_range_proof(
|
||||
current as u64,
|
||||
threshold,
|
||||
u64::MAX / 2,
|
||||
format!("Current savings >= ${:.2}", threshold as f64 / 100.0),
|
||||
"savings",
|
||||
)
|
||||
}
|
||||
|
||||
/// Prove: average spending in category <= budget
|
||||
pub fn prove_budget_compliance(
|
||||
&mut self,
|
||||
category: &str,
|
||||
budget: u64,
|
||||
) -> Result<ZkRangeProof, String> {
|
||||
// Input validation
|
||||
if category.is_empty() {
|
||||
return Err("Category must not be empty".to_string());
|
||||
}
|
||||
if budget == 0 {
|
||||
return Err("Budget must be greater than zero".to_string());
|
||||
}
|
||||
|
||||
let expenses = self
|
||||
.expenses
|
||||
.get(category)
|
||||
.ok_or_else(|| format!("No data for category: {}", category))?;
|
||||
|
||||
if expenses.is_empty() {
|
||||
return Err("No expense data for category".to_string());
|
||||
}
|
||||
|
||||
let avg_spending = expenses.iter().sum::<u64>() / expenses.len() as u64;
|
||||
|
||||
if avg_spending > budget {
|
||||
return Err(format!(
|
||||
"Average spending ${:.2} exceeds budget ${:.2}",
|
||||
avg_spending as f64 / 100.0,
|
||||
budget as f64 / 100.0
|
||||
));
|
||||
}
|
||||
|
||||
// Prove: avg_spending is in range [0, budget]
|
||||
self.create_range_proof(
|
||||
avg_spending,
|
||||
0,
|
||||
budget,
|
||||
format!(
|
||||
"Average {} spending <= ${:.2}/month",
|
||||
category,
|
||||
budget as f64 / 100.0
|
||||
),
|
||||
&format!("budget_{}", category),
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Internal
|
||||
// ========================================================================
|
||||
|
||||
/// Create a range proof using Bulletproofs
|
||||
fn create_range_proof(
|
||||
&mut self,
|
||||
value: u64,
|
||||
min: u64,
|
||||
max: u64,
|
||||
statement: String,
|
||||
key: &str,
|
||||
) -> Result<ZkRangeProof, String> {
|
||||
// Shift value to prove it's in [0, max-min]
|
||||
let shifted_value = value.checked_sub(min).ok_or("Value below minimum")?;
|
||||
let range = max.checked_sub(min).ok_or("Invalid range")?;
|
||||
|
||||
// Determine number of bits needed - Bulletproofs requires power of 2
|
||||
let raw_bits = (64 - range.leading_zeros()) as usize;
|
||||
// Round up to next power of 2: 8, 16, 32, or 64
|
||||
let bits = match raw_bits {
|
||||
0..=8 => 8,
|
||||
9..=16 => 16,
|
||||
17..=32 => 32,
|
||||
_ => 64,
|
||||
};
|
||||
|
||||
// Generate or retrieve blinding factor
|
||||
let blinding = self
|
||||
.blindings
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| Scalar::random(&mut OsRng))
|
||||
.clone();
|
||||
|
||||
// Create commitment
|
||||
let commitment = PedersenCommitment::commit_with_blinding(shifted_value, &blinding);
|
||||
|
||||
// Create Fiat-Shamir transcript
|
||||
let mut transcript = Transcript::new(TRANSCRIPT_LABEL);
|
||||
transcript.append_message(b"statement", statement.as_bytes());
|
||||
transcript.append_u64(b"min", min);
|
||||
transcript.append_u64(b"max", max);
|
||||
|
||||
// Generate Bulletproof
|
||||
let (proof, _) = BulletproofRangeProof::prove_single(
|
||||
&BP_GENS,
|
||||
&PC_GENS,
|
||||
&mut transcript,
|
||||
shifted_value,
|
||||
&blinding,
|
||||
bits,
|
||||
)
|
||||
.map_err(|e| format!("Proof generation failed: {:?}", e))?;
|
||||
|
||||
let proof_bytes = proof.to_bytes();
|
||||
let metadata = ProofMetadata::new(&proof_bytes, Some(30)); // 30 day expiry
|
||||
|
||||
Ok(ZkRangeProof {
|
||||
proof_bytes,
|
||||
commitment,
|
||||
min,
|
||||
max,
|
||||
statement,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FinancialProver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Financial Verifier
|
||||
// ============================================================================
|
||||
|
||||
/// Verifier for financial proofs
|
||||
///
|
||||
/// Verifies ZK proofs without learning private values.
|
||||
pub struct FinancialVerifier;
|
||||
|
||||
impl FinancialVerifier {
|
||||
/// Verify a range proof
|
||||
pub fn verify(proof: &ZkRangeProof) -> Result<VerificationResult, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Check expiration
|
||||
if proof.metadata.is_expired() {
|
||||
return Ok(VerificationResult {
|
||||
valid: false,
|
||||
statement: proof.statement.clone(),
|
||||
verified_at: now,
|
||||
error: Some("Proof has expired".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Verify proof hash integrity
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(&proof.proof_bytes);
|
||||
let hash_result = hasher.finalize();
|
||||
let computed_hash: [u8; 32] = hash_result[..32].try_into().unwrap();
|
||||
|
||||
if computed_hash.ct_ne(&proof.metadata.hash).into() {
|
||||
return Ok(VerificationResult {
|
||||
valid: false,
|
||||
statement: proof.statement.clone(),
|
||||
verified_at: now,
|
||||
error: Some("Proof integrity check failed".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Decompress commitment
|
||||
let commitment_point = proof
|
||||
.commitment
|
||||
.decompress()
|
||||
.ok_or("Invalid commitment point")?;
|
||||
|
||||
// Recreate transcript with same parameters
|
||||
let mut transcript = Transcript::new(TRANSCRIPT_LABEL);
|
||||
transcript.append_message(b"statement", proof.statement.as_bytes());
|
||||
transcript.append_u64(b"min", proof.min);
|
||||
transcript.append_u64(b"max", proof.max);
|
||||
|
||||
// Parse bulletproof
|
||||
let bulletproof = BulletproofRangeProof::from_bytes(&proof.proof_bytes)
|
||||
.map_err(|e| format!("Invalid proof format: {:?}", e))?;
|
||||
|
||||
// Determine bits from range - must match prover's power-of-2 calculation
|
||||
let range = proof.max.saturating_sub(proof.min);
|
||||
let raw_bits = (64 - range.leading_zeros()) as usize;
|
||||
let bits = match raw_bits {
|
||||
0..=8 => 8,
|
||||
9..=16 => 16,
|
||||
17..=32 => 32,
|
||||
_ => 64,
|
||||
};
|
||||
|
||||
// Verify the bulletproof
|
||||
let result = bulletproof.verify_single(
|
||||
&BP_GENS,
|
||||
&PC_GENS,
|
||||
&mut transcript,
|
||||
&commitment_point.compress(),
|
||||
bits,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(VerificationResult {
|
||||
valid: true,
|
||||
statement: proof.statement.clone(),
|
||||
verified_at: now,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(VerificationResult {
|
||||
valid: false,
|
||||
statement: proof.statement.clone(),
|
||||
verified_at: now,
|
||||
error: Some(format!("Verification failed: {:?}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch verify multiple proofs (more efficient)
|
||||
pub fn verify_batch(proofs: &[ZkRangeProof]) -> Vec<VerificationResult> {
|
||||
// For now, verify individually
|
||||
// TODO: Implement batch verification for efficiency
|
||||
proofs.iter().map(|p| Self::verify(p).unwrap_or_else(|e| {
|
||||
VerificationResult {
|
||||
valid: false,
|
||||
statement: p.statement.clone(),
|
||||
verified_at: 0,
|
||||
error: Some(e),
|
||||
}
|
||||
})).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Composite Proofs
|
||||
// ============================================================================
|
||||
|
||||
/// Complete rental application proof bundle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RentalApplicationBundle {
|
||||
/// Proof of income meeting affordability requirement
|
||||
pub income_proof: ZkRangeProof,
|
||||
/// Proof of no overdrafts
|
||||
pub stability_proof: ZkRangeProof,
|
||||
/// Proof of savings buffer (optional)
|
||||
pub savings_proof: Option<ZkRangeProof>,
|
||||
/// Application metadata
|
||||
pub application_id: String,
|
||||
/// When the bundle was created
|
||||
pub created_at: u64,
|
||||
/// Bundle hash for integrity
|
||||
pub bundle_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl RentalApplicationBundle {
|
||||
/// Create a complete rental application bundle
|
||||
pub fn create(
|
||||
prover: &mut FinancialProver,
|
||||
rent: u64,
|
||||
income_multiplier: u64,
|
||||
stability_days: usize,
|
||||
savings_months: Option<u64>,
|
||||
) -> Result<Self, String> {
|
||||
let income_proof = prover.prove_affordability(rent, income_multiplier)?;
|
||||
let stability_proof = prover.prove_no_overdrafts(stability_days)?;
|
||||
|
||||
let savings_proof = if let Some(months) = savings_months {
|
||||
Some(prover.prove_savings_above(rent * months)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Generate application ID
|
||||
let mut id_hasher = Sha512::new();
|
||||
id_hasher.update(&income_proof.commitment.point);
|
||||
id_hasher.update(&stability_proof.commitment.point);
|
||||
id_hasher.update(&now.to_le_bytes());
|
||||
let id_hash = id_hasher.finalize();
|
||||
let application_id = hex::encode(&id_hash[..16]);
|
||||
|
||||
// Generate bundle hash
|
||||
let mut bundle_hasher = Sha512::new();
|
||||
bundle_hasher.update(&income_proof.proof_bytes);
|
||||
bundle_hasher.update(&stability_proof.proof_bytes);
|
||||
if let Some(ref sp) = savings_proof {
|
||||
bundle_hasher.update(&sp.proof_bytes);
|
||||
}
|
||||
let bundle_hash_result = bundle_hasher.finalize();
|
||||
let mut bundle_hash = [0u8; 32];
|
||||
bundle_hash.copy_from_slice(&bundle_hash_result[..32]);
|
||||
|
||||
Ok(Self {
|
||||
income_proof,
|
||||
stability_proof,
|
||||
savings_proof,
|
||||
application_id,
|
||||
created_at: now,
|
||||
bundle_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify the entire bundle
|
||||
pub fn verify(&self) -> Result<bool, String> {
|
||||
// Verify bundle integrity
|
||||
let mut bundle_hasher = Sha512::new();
|
||||
bundle_hasher.update(&self.income_proof.proof_bytes);
|
||||
bundle_hasher.update(&self.stability_proof.proof_bytes);
|
||||
if let Some(ref sp) = self.savings_proof {
|
||||
bundle_hasher.update(&sp.proof_bytes);
|
||||
}
|
||||
let computed_hash = bundle_hasher.finalize();
|
||||
|
||||
if computed_hash[..32].ct_ne(&self.bundle_hash).into() {
|
||||
return Err("Bundle integrity check failed".to_string());
|
||||
}
|
||||
|
||||
// Verify individual proofs
|
||||
let income_result = FinancialVerifier::verify(&self.income_proof)?;
|
||||
if !income_result.valid {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let stability_result = FinancialVerifier::verify(&self.stability_proof)?;
|
||||
if !stability_result.valid {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(ref savings_proof) = self.savings_proof {
|
||||
let savings_result = FinancialVerifier::verify(savings_proof)?;
|
||||
if !savings_result.valid {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_income_proof() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_income(vec![650000, 650000, 680000, 650000]); // ~$6500/month
|
||||
|
||||
// Should succeed: income > $5000
|
||||
let proof = prover.prove_income_above(500000).unwrap();
|
||||
let result = FinancialVerifier::verify(&proof).unwrap();
|
||||
assert!(result.valid, "Proof should be valid");
|
||||
|
||||
// Should fail: income < $10000
|
||||
let result = prover.prove_income_above(1000000);
|
||||
assert!(result.is_err(), "Should fail for threshold above income");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_affordability_proof() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_income(vec![650000, 650000, 650000, 650000]); // $6500/month
|
||||
|
||||
// Should succeed: $6500 >= 3 × $2000
|
||||
let proof = prover.prove_affordability(200000, 3).unwrap();
|
||||
let result = FinancialVerifier::verify(&proof).unwrap();
|
||||
assert!(result.valid);
|
||||
|
||||
// Should fail: $6500 < 3 × $3000
|
||||
let result = prover.prove_affordability(300000, 3);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_overdraft_proof() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_balances(vec![100000, 80000, 120000, 50000, 90000]); // All positive
|
||||
|
||||
let proof = prover.prove_no_overdrafts(5).unwrap();
|
||||
let result = FinancialVerifier::verify(&proof).unwrap();
|
||||
assert!(result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overdraft_fails() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_balances(vec![100000, -5000, 120000]); // Has overdraft
|
||||
|
||||
let result = prover.prove_no_overdrafts(3);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rental_application_bundle() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_income(vec![650000, 650000, 680000, 650000]);
|
||||
prover.set_balances(vec![500000, 520000, 480000, 510000, 530000]);
|
||||
|
||||
let bundle = RentalApplicationBundle::create(
|
||||
&mut prover,
|
||||
200000, // $2000 rent
|
||||
3, // 3x income
|
||||
30, // 30 days stability
|
||||
Some(2), // 2 months savings
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(bundle.verify().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proof_expiration() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_income(vec![650000]);
|
||||
|
||||
let mut proof = prover.prove_income_above(500000).unwrap();
|
||||
|
||||
// Manually expire the proof
|
||||
proof.metadata.expires_at = Some(0);
|
||||
|
||||
let result = FinancialVerifier::verify(&proof).unwrap();
|
||||
assert!(!result.valid);
|
||||
assert!(result.error.as_ref().unwrap().contains("expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proof_integrity() {
|
||||
let mut prover = FinancialProver::new();
|
||||
prover.set_income(vec![650000]);
|
||||
|
||||
let mut proof = prover.prove_income_above(500000).unwrap();
|
||||
|
||||
// Tamper with the proof
|
||||
if !proof.proof_bytes.is_empty() {
|
||||
proof.proof_bytes[0] ^= 0xFF;
|
||||
}
|
||||
|
||||
let result = FinancialVerifier::verify(&proof).unwrap();
|
||||
assert!(!result.valid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user