use serde::{Deserialize, Serialize};
use crate::broker::node_identity::{now_secs, verify_sig};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Voucher {
pub v: u8, pub requester_user: String,
pub budget_credits: f64,
pub task_nonce: String,
pub exp: u64, #[serde(default)]
pub commission_c: f64, }
pub fn verify_voucher(
dash_pubkey_b64: &str,
signed_json: &str,
sig_b64: &str,
now: u64,
price_estimate: f64,
) -> Result<Voucher, String> {
if !verify_sig(dash_pubkey_b64, signed_json.as_bytes(), sig_b64) {
return Err("bad voucher signature".into());
}
let vch: Voucher =
serde_json::from_str(signed_json).map_err(|e| format!("malformed voucher: {e}"))?;
if vch.v != 1 {
return Err(format!("unsupported voucher version {}", vch.v));
}
if vch.exp <= now {
return Err("voucher expired".into());
}
if price_estimate > vch.budget_credits {
return Err(format!(
"price estimate {:.6} exceeds voucher budget {:.6}",
price_estimate, vch.budget_credits
));
}
if !(0.0 <= vch.commission_c && vch.commission_c < 1.0) {
return Err(format!("invalid voucher commission_c {}", vch.commission_c));
}
Ok(vch)
}
pub fn verify_voucher_now(
dash_pubkey_b64: &str,
signed_json: &str,
sig_b64: &str,
price_estimate: f64,
) -> Result<Voucher, String> {
verify_voucher(
dash_pubkey_b64,
signed_json,
sig_b64,
now_secs(),
price_estimate,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broker::node_identity::NodeKey;
const SIGNED: &str = r#"{"v":1,"requester_user":"1000000001","budget_credits":5.0,"task_nonce":"n-abc","exp":10000}"#;
#[test]
fn valid_voucher_passes_and_parses() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED.as_bytes());
let vch = verify_voucher(&dash.public_b64(), SIGNED, &sig, 9_000, 3.0).unwrap();
assert_eq!(vch.requester_user, "1000000001");
assert_eq!(vch.budget_credits, 5.0);
}
#[test]
fn expired_rejected() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED.as_bytes());
let err = verify_voucher(&dash.public_b64(), SIGNED, &sig, 10_001, 1.0).unwrap_err();
assert!(err.contains("expired"));
}
#[test]
fn bad_signature_rejected() {
let dash = NodeKey::generate();
let other = NodeKey::generate();
let sig = other.sign(SIGNED.as_bytes()); let err = verify_voucher(&dash.public_b64(), SIGNED, &sig, 9_000, 1.0).unwrap_err();
assert!(err.contains("signature"));
}
#[test]
fn over_budget_rejected() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED.as_bytes());
let err = verify_voucher(&dash.public_b64(), SIGNED, &sig, 9_000, 9.0).unwrap_err();
assert!(err.contains("exceeds voucher budget"));
}
#[test]
#[ignore = "cross-language interop; run with env from the Python signer"]
fn python_signed_voucher_verifies() {
use base64::Engine;
let pk = std::env::var("ZC_PUB").expect("ZC_PUB");
let sig = std::env::var("ZC_SIG").expect("ZC_SIG");
let msg_b64 = std::env::var("ZC_MSG_B64").expect("ZC_MSG_B64");
let msg = String::from_utf8(
base64::engine::general_purpose::STANDARD
.decode(msg_b64)
.unwrap(),
)
.unwrap();
let vch = verify_voucher(&pk, &msg, &sig, 0, 1.0).expect("python sig verifies in ring");
assert_eq!(vch.requester_user, "1000000001");
}
const SIGNED_WITH_COMMISSION: &str = r#"{"v":1,"requester_user":"1000000001","budget_credits":5.0,"task_nonce":"n-abc","exp":10000,"commission_c":0.1}"#;
#[test]
fn commission_c_parses_and_verifies() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED_WITH_COMMISSION.as_bytes());
let vch =
verify_voucher(&dash.public_b64(), SIGNED_WITH_COMMISSION, &sig, 9_000, 3.0).unwrap();
assert_eq!(vch.commission_c, 0.1);
}
#[test]
fn commission_c_of_one_rejected() {
let dash = NodeKey::generate();
let json = SIGNED_WITH_COMMISSION.replace("\"commission_c\":0.1", "\"commission_c\":1.0");
let sig = dash.sign(json.as_bytes());
let err = verify_voucher(&dash.public_b64(), &json, &sig, 9_000, 3.0).unwrap_err();
assert!(err.contains("commission_c"));
}
#[test]
fn commission_c_negative_rejected() {
let dash = NodeKey::generate();
let json = SIGNED_WITH_COMMISSION.replace("\"commission_c\":0.1", "\"commission_c\":-0.5");
let sig = dash.sign(json.as_bytes());
let err = verify_voucher(&dash.public_b64(), &json, &sig, 9_000, 3.0).unwrap_err();
assert!(err.contains("commission_c"));
}
#[test]
fn legacy_voucher_without_commission_c_defaults_and_verifies() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED.as_bytes()); let vch = verify_voucher(&dash.public_b64(), SIGNED, &sig, 9_000, 3.0).unwrap();
assert_eq!(vch.commission_c, 0.0);
}
#[test]
fn tampered_bytes_break_signature() {
let dash = NodeKey::generate();
let sig = dash.sign(SIGNED.as_bytes());
let forged = SIGNED.replace("\"budget_credits\":5.0", "\"budget_credits\":999.0");
let err = verify_voucher(&dash.public_b64(), &forged, &sig, 9_000, 100.0).unwrap_err();
assert!(err.contains("signature"));
}
}