Skip to main content

ves_stark_nodejs/
lib.rs

1//! Node.js bindings for VES STARK proof system
2//!
3//! This crate provides Node.js bindings for generating and verifying
4//! STARK compliance proofs using NAPI-RS.
5
6use napi::bindgen_prelude::*;
7use napi_derive::napi;
8use uuid::Uuid;
9
10use ves_stark_air::Policy;
11use ves_stark_primitives::{
12    witness_commitment_hex_to_u64, CommerceAuthorizationReceipt, CompliancePublicInputs,
13    PayloadAmountBinding, PolicyParams,
14};
15use ves_stark_prover::{ComplianceProver, ComplianceWitness};
16use ves_stark_verifier::{
17    verify_agent_authorization_proof_auto_with_amount_binding, verify_compliance_proof_auto_bound,
18    verify_compliance_proof_auto_with_amount_binding, VerifierError,
19};
20
21fn bigint_to_u64(value: &BigInt, field_name: &str) -> Result<u64> {
22    let (sign_bit, value, lossless) = value.get_u64();
23    if sign_bit {
24        return Err(Error::new(
25            Status::InvalidArg,
26            format!("{} must be non-negative", field_name),
27        ));
28    }
29    if !lossless {
30        return Err(Error::new(
31            Status::InvalidArg,
32            format!("{} must fit in u64", field_name),
33        ));
34    }
35    Ok(value)
36}
37
38fn parse_witness_commitment(witness_commitment: Vec<String>) -> Result<[u64; 4]> {
39    if witness_commitment.len() != 4 {
40        return Err(Error::new(
41            Status::InvalidArg,
42            format!(
43                "Witness commitment must have exactly 4 elements, got {}",
44                witness_commitment.len()
45            ),
46        ));
47    }
48
49    let mut commitment = [0u64; 4];
50    for (idx, value) in witness_commitment.iter().enumerate() {
51        let parsed = value.parse::<u64>().map_err(|_| {
52            Error::new(
53                Status::InvalidArg,
54                "Invalid witness commitment element".to_string(),
55            )
56        })?;
57        commitment[idx] = parsed;
58    }
59
60    Ok(commitment)
61}
62
63fn verifier_error_to_napi(err: VerifierError) -> Error {
64    let status = match err {
65        VerifierError::PublicInputMismatch(_)
66        | VerifierError::InvalidHexFormat { .. }
67        | VerifierError::DeserializationError(_)
68        | VerifierError::InvalidPolicyHash { .. }
69        | VerifierError::PolicyMismatch { .. }
70        | VerifierError::LimitMismatch { .. }
71        | VerifierError::PayloadAmountBindingRequired(_)
72        | VerifierError::WitnessCommitmentMismatch
73        | VerifierError::ProofTooLarge { .. }
74        | VerifierError::UnsupportedProofVersion { .. } => Status::InvalidArg,
75        VerifierError::InvalidProofStructure(_)
76        | VerifierError::FriVerificationFailed(_)
77        | VerifierError::ConstraintCheckFailed(_)
78        | VerifierError::VerificationFailed(_) => Status::GenericFailure,
79    };
80
81    Error::new(status, format!("Verification error: {}", err))
82}
83
84fn bind_public_inputs_to_commitment(
85    mut public_inputs: CompliancePublicInputs,
86    witness_commitment: &[u64; 4],
87) -> Result<CompliancePublicInputs> {
88    public_inputs = public_inputs
89        .bind_witness_commitment(witness_commitment)
90        .map_err(|e| {
91            Error::new(
92                Status::InvalidArg,
93                format!("Failed to bind witness commitment to public inputs: {}", e),
94            )
95        })?;
96    Ok(public_inputs)
97}
98
99fn parse_authorization_receipt(receipt: serde_json::Value) -> Result<CommerceAuthorizationReceipt> {
100    serde_json::from_value(receipt).map_err(|e| {
101        Error::new(
102            Status::InvalidArg,
103            format!("Invalid authorization receipt object: {}", e),
104        )
105    })
106}
107
108fn parse_payload_amount_binding(binding: serde_json::Value) -> Result<PayloadAmountBinding> {
109    serde_json::from_value(binding).map_err(|e| {
110        Error::new(
111            Status::InvalidArg,
112            format!("Invalid payload amount binding object: {}", e),
113        )
114    })
115}
116
117/// Public inputs for compliance proof generation/verification
118#[napi(object)]
119pub struct JsCompliancePublicInputs {
120    /// UUID of the event being proven
121    pub event_id: String,
122    /// Tenant ID
123    pub tenant_id: String,
124    /// Store ID
125    pub store_id: String,
126    /// Sequence number of the event
127    pub sequence_number: BigInt,
128    /// Payload kind (event type discriminator)
129    pub payload_kind: u32,
130    /// SHA-256 hash of plaintext payload (hex64, lowercase)
131    pub payload_plain_hash: String,
132    /// SHA-256 hash of ciphertext payload (hex64, lowercase)
133    pub payload_cipher_hash: String,
134    /// Event signing hash (hex64, lowercase)
135    pub event_signing_hash: String,
136    /// Policy identifier (e.g., "aml.threshold")
137    pub policy_id: String,
138    /// Policy parameters as JSON object
139    pub policy_params: serde_json::Value,
140    /// Policy hash (hex64, lowercase)
141    pub policy_hash: String,
142    /// Optional witness commitment (hex64, lowercase) to bind the proved witness to canonical inputs.
143    pub witness_commitment: Option<String>,
144    /// Optional authorization receipt hash (hex64, lowercase) committed into canonical public inputs.
145    pub authorization_receipt_hash: Option<String>,
146    /// Optional payload amount binding hash (hex64, lowercase) committed into canonical public inputs.
147    pub amount_binding_hash: Option<String>,
148}
149
150/// Result of proof generation
151#[napi(object)]
152pub struct JsComplianceProof {
153    /// Raw proof bytes
154    pub proof_bytes: Buffer,
155    /// SHA-256 hash of proof bytes (hex)
156    pub proof_hash: String,
157    /// Time taken to generate proof in milliseconds
158    pub proving_time_ms: i64,
159    /// Size of proof in bytes
160    pub proof_size: i64,
161    /// Witness commitment (4 x u64 as field elements)
162    pub witness_commitment: Vec<String>,
163    /// Witness commitment encoded as 32 bytes (4 x u64 big-endian) and hex-encoded (64 chars).
164    pub witness_commitment_hex: String,
165}
166
167/// Result of proof verification
168#[napi(object)]
169pub struct JsVerificationResult {
170    /// Whether the proof is valid
171    pub valid: bool,
172    /// Time taken to verify in milliseconds
173    pub verification_time_ms: i64,
174    /// Error message if verification failed
175    pub error: Option<String>,
176    /// Policy ID that was verified
177    pub policy_id: String,
178    /// Policy limit that was verified against
179    pub policy_limit: BigInt,
180}
181
182/// Convert JS public inputs to Rust struct
183fn convert_public_inputs(js: &JsCompliancePublicInputs) -> Result<CompliancePublicInputs> {
184    let event_id = Uuid::parse_str(&js.event_id)
185        .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid event_id UUID: {}", e)))?;
186    let tenant_id = Uuid::parse_str(&js.tenant_id)
187        .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid tenant_id UUID: {}", e)))?;
188    let store_id = Uuid::parse_str(&js.store_id)
189        .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid store_id UUID: {}", e)))?;
190
191    Ok(CompliancePublicInputs {
192        event_id,
193        tenant_id,
194        store_id,
195        sequence_number: bigint_to_u64(&js.sequence_number, "sequence_number")?,
196        payload_kind: js.payload_kind,
197        payload_plain_hash: js.payload_plain_hash.clone(),
198        payload_cipher_hash: js.payload_cipher_hash.clone(),
199        event_signing_hash: js.event_signing_hash.clone(),
200        policy_id: js.policy_id.clone(),
201        policy_params: PolicyParams(js.policy_params.clone()),
202        policy_hash: js.policy_hash.clone(),
203        witness_commitment: js.witness_commitment.clone(),
204        authorization_receipt_hash: js.authorization_receipt_hash.clone(),
205        amount_binding_hash: js.amount_binding_hash.clone(),
206    })
207}
208
209/// Generate a STARK compliance proof for the provided amount witness.
210///
211/// @param amount - The amount to prove compliance for (must satisfy the policy constraint)
212/// @param publicInputs - Public inputs including event metadata and policy info
213/// @param policyType - Policy type: "aml.threshold", "order_total.cap", or "agent.authorization.v1"
214/// @param policyLimit - The policy limit (threshold, cap, or maxTotal value)
215/// @returns ComplianceProof containing proof bytes and metadata
216///
217/// Note: this proves a statement about the supplied `amount` witness. Binding that
218/// witness back to encrypted payload contents is the responsibility of the
219/// surrounding pipeline, not this library.
220#[napi]
221pub fn prove(
222    amount: BigInt,
223    public_inputs: JsCompliancePublicInputs,
224    policy_type: String,
225    policy_limit: BigInt,
226) -> Result<JsComplianceProof> {
227    let amount = bigint_to_u64(&amount, "amount")?;
228    let policy_limit = bigint_to_u64(&policy_limit, "policy_limit")?;
229
230    // Convert public inputs
231    let rust_inputs = convert_public_inputs(&public_inputs)?;
232    if rust_inputs.policy_id != policy_type {
233        return Err(Error::new(
234            Status::InvalidArg,
235            format!(
236                "policyType {} does not match publicInputs.policyId {}",
237                policy_type, rust_inputs.policy_id
238            ),
239        ));
240    }
241
242    let policy = Policy::from_public_inputs(&rust_inputs.policy_id, &rust_inputs.policy_params)
243        .map_err(|e| {
244            Error::new(
245                Status::InvalidArg,
246                format!("Invalid policy parameters for {}: {}", policy_type, e),
247            )
248        })?;
249    if policy.limit() != policy_limit {
250        return Err(Error::new(
251            Status::InvalidArg,
252            format!(
253                "policyLimit {} does not match publicInputs policy limit {}",
254                policy_limit,
255                policy.limit()
256            ),
257        ));
258    }
259    if !policy.validate_amount(amount) {
260        return Err(Error::new(
261            Status::InvalidArg,
262            format!(
263                "amount must be {} policy limit for {}",
264                match policy_type.as_str() {
265                    "aml.threshold" => "<",
266                    _ => "<=",
267                },
268                policy_type
269            ),
270        ));
271    }
272
273    // Create witness
274    let witness = ComplianceWitness::try_new(amount, rust_inputs).map_err(|e| {
275        Error::new(
276            Status::InvalidArg,
277            format!("Invalid witness/public inputs: {}", e),
278        )
279    })?;
280
281    // Create prover and generate proof
282    let prover = ComplianceProver::with_policy(policy);
283    let proof = prover.prove(&witness).map_err(|e| {
284        Error::new(
285            Status::GenericFailure,
286            format!("Proof generation failed: {}", e),
287        )
288    })?;
289
290    let witness_commitment: Vec<String> = proof
291        .witness_commitment
292        .iter()
293        .map(|value| value.to_string())
294        .collect();
295    let witness_commitment_hex = proof.witness_commitment_hex.clone().ok_or_else(|| {
296        Error::new(
297            Status::GenericFailure,
298            "Missing witness_commitment_hex in proof".to_string(),
299        )
300    })?;
301
302    Ok(JsComplianceProof {
303        proof_bytes: Buffer::from(proof.proof_bytes),
304        proof_hash: proof.proof_hash,
305        proving_time_ms: proof.metadata.proving_time_ms as i64,
306        proof_size: proof.metadata.proof_size as i64,
307        witness_commitment,
308        witness_commitment_hex,
309    })
310}
311
312/// Verify a STARK compliance proof
313///
314/// @param proofBytes - The raw proof bytes from prove()
315/// @param publicInputs - Public inputs (must match those used for proving)
316/// @param witnessCommitment - Witness commitment from the proof
317/// @returns VerificationResult indicating if proof is valid
318///
319/// Malformed public inputs, malformed proof encodings, or witness-commitment binding
320/// mismatches are reported as thrown errors rather than `valid = false`.
321#[napi]
322pub fn verify(
323    proof_bytes: Buffer,
324    public_inputs: JsCompliancePublicInputs,
325    witness_commitment: Vec<String>,
326) -> Result<JsVerificationResult> {
327    // Convert public inputs
328    let rust_inputs = convert_public_inputs(&public_inputs)?;
329
330    let commitment = parse_witness_commitment(witness_commitment)?;
331    let rust_inputs = bind_public_inputs_to_commitment(rust_inputs, &commitment)?;
332
333    // Verify proof
334    let result = verify_compliance_proof_auto_bound(&proof_bytes, &rust_inputs);
335
336    match result {
337        Ok(verification) => Ok(JsVerificationResult {
338            valid: verification.valid,
339            verification_time_ms: verification.verification_time_ms as i64,
340            error: verification.error,
341            policy_id: verification.policy_id,
342            policy_limit: BigInt::from(verification.policy_limit),
343        }),
344        Err(e) => Err(verifier_error_to_napi(e)),
345    }
346}
347
348/// Verify a STARK compliance proof using the witness commitment hex string.
349///
350/// This avoids `u64` round-trip issues in JavaScript.
351/// Malformed public inputs, malformed proof encodings, or witness-commitment binding
352/// mismatches are reported as thrown errors rather than `valid = false`.
353#[napi]
354pub fn verify_hex(
355    proof_bytes: Buffer,
356    public_inputs: JsCompliancePublicInputs,
357    witness_commitment_hex: String,
358) -> Result<JsVerificationResult> {
359    // Convert public inputs
360    let rust_inputs = convert_public_inputs(&public_inputs)?;
361
362    let commitment = witness_commitment_hex_to_u64(&witness_commitment_hex).map_err(|e| {
363        Error::new(
364            Status::InvalidArg,
365            format!("Invalid witnessCommitmentHex: {}", e),
366        )
367    })?;
368    let rust_inputs = bind_public_inputs_to_commitment(rust_inputs, &commitment)?;
369
370    // Verify proof
371    let result = verify_compliance_proof_auto_bound(&proof_bytes, &rust_inputs);
372
373    match result {
374        Ok(verification) => Ok(JsVerificationResult {
375            valid: verification.valid,
376            verification_time_ms: verification.verification_time_ms as i64,
377            error: verification.error,
378            policy_id: verification.policy_id,
379            policy_limit: BigInt::from(verification.policy_limit),
380        }),
381        Err(e) => Err(verifier_error_to_napi(e)),
382    }
383}
384
385/// Verify a STARK compliance proof against a canonical payload amount binding.
386#[napi]
387pub fn verify_with_amount_binding(
388    proof_bytes: Buffer,
389    public_inputs: JsCompliancePublicInputs,
390    amount_binding: serde_json::Value,
391) -> Result<JsVerificationResult> {
392    let rust_inputs = convert_public_inputs(&public_inputs)?;
393    let binding = parse_payload_amount_binding(amount_binding)?;
394
395    let result =
396        verify_compliance_proof_auto_with_amount_binding(&proof_bytes, &rust_inputs, &binding);
397
398    match result {
399        Ok(verification) => Ok(JsVerificationResult {
400            valid: verification.valid,
401            verification_time_ms: verification.verification_time_ms as i64,
402            error: verification.error,
403            policy_id: verification.policy_id,
404            policy_limit: BigInt::from(verification.policy_limit),
405        }),
406        Err(e) => Err(verifier_error_to_napi(e)),
407    }
408}
409
410/// Verify an `agent.authorization.v1` proof against a canonical authorization receipt.
411#[napi]
412pub fn verify_agent_authorization(
413    proof_bytes: Buffer,
414    public_inputs: JsCompliancePublicInputs,
415    witness_commitment: Vec<String>,
416    receipt: serde_json::Value,
417) -> Result<JsVerificationResult> {
418    let rust_inputs = convert_public_inputs(&public_inputs)?;
419    let commitment = parse_witness_commitment(witness_commitment)?;
420    let rust_inputs = bind_public_inputs_to_commitment(rust_inputs, &commitment)?;
421    let receipt = parse_authorization_receipt(receipt)?;
422    let binding = rust_inputs
423        .payload_amount_binding(receipt.amount)
424        .map_err(|e| verifier_error_to_napi(VerifierError::PublicInputMismatch(format!("{e}"))))?;
425
426    let result = verify_agent_authorization_proof_auto_with_amount_binding(
427        &proof_bytes,
428        &rust_inputs,
429        &binding,
430        &receipt,
431    );
432
433    match result {
434        Ok(verification) => Ok(JsVerificationResult {
435            valid: verification.valid,
436            verification_time_ms: verification.verification_time_ms as i64,
437            error: verification.error,
438            policy_id: verification.policy_id,
439            policy_limit: BigInt::from(verification.policy_limit),
440        }),
441        Err(e) => Err(verifier_error_to_napi(e)),
442    }
443}
444
445/// Verify an `agent.authorization.v1` proof using the witness commitment hex string.
446#[napi]
447pub fn verify_agent_authorization_hex(
448    proof_bytes: Buffer,
449    public_inputs: JsCompliancePublicInputs,
450    witness_commitment_hex: String,
451    receipt: serde_json::Value,
452) -> Result<JsVerificationResult> {
453    let rust_inputs = convert_public_inputs(&public_inputs)?;
454    let commitment = witness_commitment_hex_to_u64(&witness_commitment_hex).map_err(|e| {
455        Error::new(
456            Status::InvalidArg,
457            format!("Invalid witnessCommitmentHex: {}", e),
458        )
459    })?;
460    let rust_inputs = bind_public_inputs_to_commitment(rust_inputs, &commitment)?;
461    let receipt = parse_authorization_receipt(receipt)?;
462    let binding = rust_inputs
463        .payload_amount_binding(receipt.amount)
464        .map_err(|e| verifier_error_to_napi(VerifierError::PublicInputMismatch(format!("{e}"))))?;
465
466    let result = verify_agent_authorization_proof_auto_with_amount_binding(
467        &proof_bytes,
468        &rust_inputs,
469        &binding,
470        &receipt,
471    );
472
473    match result {
474        Ok(verification) => Ok(JsVerificationResult {
475            valid: verification.valid,
476            verification_time_ms: verification.verification_time_ms as i64,
477            error: verification.error,
478            policy_id: verification.policy_id,
479            policy_limit: BigInt::from(verification.policy_limit),
480        }),
481        Err(e) => Err(verifier_error_to_napi(e)),
482    }
483}
484
485/// Verify an `agent.authorization.v1` proof against both a canonical payload amount binding and a
486/// canonical authorization receipt.
487#[napi]
488pub fn verify_agent_authorization_with_amount_binding(
489    proof_bytes: Buffer,
490    public_inputs: JsCompliancePublicInputs,
491    amount_binding: serde_json::Value,
492    receipt: serde_json::Value,
493) -> Result<JsVerificationResult> {
494    let rust_inputs = convert_public_inputs(&public_inputs)?;
495    let binding = parse_payload_amount_binding(amount_binding)?;
496    let receipt = parse_authorization_receipt(receipt)?;
497
498    let result = verify_agent_authorization_proof_auto_with_amount_binding(
499        &proof_bytes,
500        &rust_inputs,
501        &binding,
502        &receipt,
503    );
504
505    match result {
506        Ok(verification) => Ok(JsVerificationResult {
507            valid: verification.valid,
508            verification_time_ms: verification.verification_time_ms as i64,
509            error: verification.error,
510            policy_id: verification.policy_id,
511            policy_limit: BigInt::from(verification.policy_limit),
512        }),
513        Err(e) => Err(verifier_error_to_napi(e)),
514    }
515}
516
517/// Compute the policy hash for given policy ID and parameters
518///
519/// @param policyId - Policy identifier (e.g., "aml.threshold")
520/// @param policyParams - Policy parameters as JSON object
521/// @returns Policy hash as hex string (64 characters, lowercase)
522#[napi]
523pub fn compute_policy_hash(policy_id: String, policy_params: serde_json::Value) -> Result<String> {
524    let params = PolicyParams(policy_params);
525    let hash = ves_stark_primitives::compute_policy_hash(&policy_id, &params).map_err(|e| {
526        Error::new(
527            Status::GenericFailure,
528            format!("Failed to compute policy hash: {}", e),
529        )
530    })?;
531    Ok(hash.to_hex())
532}
533
534/// Create policy parameters for AML threshold policy
535///
536/// @param threshold - The AML threshold value
537/// @returns Policy parameters JSON object
538#[napi]
539pub fn create_aml_threshold_params(threshold: BigInt) -> Result<serde_json::Value> {
540    let threshold = bigint_to_u64(&threshold, "threshold")?;
541    Ok(serde_json::json!({ "threshold": threshold }))
542}
543
544/// Create policy parameters for order total cap policy
545///
546/// @param cap - The order total cap value
547/// @returns Policy parameters JSON object
548#[napi]
549pub fn create_order_total_cap_params(cap: BigInt) -> Result<serde_json::Value> {
550    let cap = bigint_to_u64(&cap, "cap")?;
551    Ok(serde_json::json!({ "cap": cap }))
552}
553
554/// Create policy parameters for agent authorization policy.
555///
556/// @param maxTotal - The maximum delegated total
557/// @param intentHash - The delegated commerce intent hash (hex64, lowercase recommended)
558/// @returns Policy parameters JSON object
559#[napi]
560pub fn create_agent_authorization_params(
561    max_total: BigInt,
562    intent_hash: String,
563) -> Result<serde_json::Value> {
564    let max_total = bigint_to_u64(&max_total, "max_total")?;
565    let params = PolicyParams::agent_authorization(max_total, &intent_hash).map_err(|e| {
566        Error::new(
567            Status::InvalidArg,
568            format!("Invalid agent authorization params: {}", e),
569        )
570    })?;
571    Ok(params.to_json_value())
572}
573
574/// Create a canonical payload amount binding for the supplied public inputs and extracted amount.
575#[napi]
576pub fn create_payload_amount_binding(
577    public_inputs: JsCompliancePublicInputs,
578    amount: BigInt,
579) -> Result<serde_json::Value> {
580    let rust_inputs = convert_public_inputs(&public_inputs)?;
581    let amount = bigint_to_u64(&amount, "amount")?;
582
583    let binding = rust_inputs.payload_amount_binding(amount).map_err(|e| {
584        Error::new(
585            Status::InvalidArg,
586            format!("Invalid payload amount binding inputs: {}", e),
587        )
588    })?;
589
590    serde_json::to_value(&binding).map_err(|e| {
591        Error::new(
592            Status::GenericFailure,
593            format!("Failed to serialize payload amount binding: {}", e),
594        )
595    })
596}