zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Dashboard-signed job vouchers, verified OFFLINE by the executing broker.
//!
//! The requester reserves credits with the dashboard (its own `zk_` key) and gets a
//! signed voucher; it travels with the dispatched job. The executor verifies the
//! dashboard's Ed25519 signature against a cached pubkey — no per-job dashboard call —
//! then redeems the actual cost asynchronously. Nodes never trust each other's balance.
//!
//! Cross-language contract: the signature covers the **exact JSON bytes** the dashboard
//! emitted (`signed_json`), carried verbatim on the wire. The broker verifies over those
//! bytes and only then parses fields — it never re-serializes (float/whitespace
//! formatting differs between Rust and the Python dashboard and would break the sig).

use serde::{Deserialize, Serialize};

use crate::broker::node_identity::{now_secs, verify_sig};

/// A dashboard-issued authorization to spend up to `budget_credits` on one job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Voucher {
    pub v: u8, // schema version (1)
    pub requester_user: String,
    pub budget_credits: f64,
    pub task_nonce: String,
    pub exp: u64, // unix secs
    #[serde(default)]
    pub commission_c: f64, // per-tier commission rate, [0.0, 1.0)
}

/// Verify a voucher offline and return the parsed fields.
///
/// - `signed_json`: the exact bytes the dashboard signed (carried verbatim).
/// - `sig_b64`: the dashboard's Ed25519 signature over `signed_json`.
///
/// Checks: signature, version, expiry, and that the job's estimated price fits budget.
pub fn verify_voucher(
    dash_pubkey_b64: &str,
    signed_json: &str,
    sig_b64: &str,
    now: u64,
    price_estimate: f64,
) -> Result<Voucher, String> {
    // 1. signature over the verbatim bytes — before trusting any field.
    if !verify_sig(dash_pubkey_b64, signed_json.as_bytes(), sig_b64) {
        return Err("bad voucher signature".into());
    }
    // 2. parse only after the signature checks out.
    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)
}

/// Convenience: verify against the current clock.
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;

    // The dashboard would emit compact JSON like this; we sign these exact bytes.
    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()); // wrong signer
        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() {
        // Values produced by zak-dashboard's cryptography Ed25519 signer.
        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()); // legacy JSON, no commission_c field
        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());
        // raise the budget after signing — verifying the altered bytes must fail
        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"));
    }
}