trazaeo 0.5.0

Open-source provenance SDK and specification for verifiable EO and climate data workflows
Documentation
use crate::envelope::PublishEnvelope;
use crate::error::{TrazaeoError, TrazaeoResult};
use crate::proof_log::{verify_proof_log_commitment_linkage, ProofLogCommitment};
use crate::verification::{is_report_success, VerificationReport};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewardClaim {
    pub reward_claim_id: String,
    pub actor_id: String,
    pub action_type: String,
    pub event_time_window: String,
    pub evidence_refs: Vec<String>,
    pub proof_log_entry_refs: Vec<String>,
    pub quality_score_ref: Option<String>,
    pub payout_recipient: String,
    pub payout_policy_id: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettlementReceipt {
    pub settlement_id: String,
    pub reward_claim_id: String,
    pub settled_at: String,
    pub settlement_hash: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettlementDomain {
    pub chain_id: String,
    pub settlement_window: String,
    pub policy_id: String,
    pub schema_version: String,
    pub nonce: String,
}

/// Builds reward claim.
pub fn build_reward_claim(
    claim: RewardClaim,
    verification_report: &VerificationReport,
    publish_envelope: &PublishEnvelope,
    proof_log_commitments: &[ProofLogCommitment],
) -> TrazaeoResult<RewardClaim> {
    if !is_report_success(verification_report) {
        return Err(TrazaeoError::invalid_input(
            "build reward claim",
            "verification report failed",
        ));
    }
    if claim.evidence_refs.is_empty() {
        return Err(TrazaeoError::invalid_input(
            "build reward claim",
            "missing evidence refs",
        ));
    }
    if claim.proof_log_entry_refs.is_empty() {
        return Err(TrazaeoError::invalid_input(
            "build reward claim",
            "missing proof log entry refs",
        ));
    }
    for entry_id in &claim.proof_log_entry_refs {
        let Some(commitment) = proof_log_commitments
            .iter()
            .find(|a| &a.entry_id == entry_id)
        else {
            return Err(TrazaeoError::invalid_input(
                "build reward claim",
                format!("unknown proof log entry ref: {entry_id}"),
            ));
        };
        if verify_proof_log_commitment_linkage(publish_envelope, commitment).is_err() {
            return Err(TrazaeoError::invalid_input(
                "build reward claim",
                format!("invalid proof log linkage for entry ref: {entry_id}"),
            ));
        }
    }
    Ok(claim)
}

/// Handles settle reward claim.
pub fn settle_reward_claim(
    claim: &RewardClaim,
    settled_at: &str,
    domain: &SettlementDomain,
) -> SettlementReceipt {
    let payload = format!(
        "trazaeo:settlement:{}:{}:{}:{}:{}:{}:{}:{}:{}",
        claim.reward_claim_id,
        claim.actor_id,
        claim.payout_recipient,
        settled_at,
        domain.chain_id,
        domain.settlement_window,
        domain.policy_id,
        domain.schema_version,
        domain.nonce,
    );
    let settlement_hash = hex::encode(blake3::hash(payload.as_bytes()).as_bytes());
    SettlementReceipt {
        settlement_id: format!("settlement-{}", claim.reward_claim_id),
        reward_claim_id: claim.reward_claim_id.clone(),
        settled_at: settled_at.to_string(),
        settlement_hash,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::envelope::{make_attestation, PublishEnvelope};
    use crate::verification::{VerificationMode, VerificationState};

    /// Handles report ok.
    fn report_ok() -> VerificationReport {
        VerificationReport {
            mode: VerificationMode::Sampled,
            signature_state: VerificationState::Passed,
            trust_state: VerificationState::Passed,
            binding_state: VerificationState::Passed,
            reproducibility_state: VerificationState::NotEvaluated,
            lineage_state: VerificationState::Passed,
            storage_state: VerificationState::NotEvaluated,
            proof_log_state: VerificationState::NotEvaluated,
            reasons: vec![],
        }
    }

    /// Handles proof-log commitment.
    fn proof_log_commitment() -> ProofLogCommitment {
        ProofLogCommitment {
            entry_id: "tx-1".to_string(),
            envelope_hash: "e".to_string(),
            checkpoint_hash: "checkpoint-hash".to_string(),
            log_root_hash: "log-root".to_string(),
            committed_at: "2026-01-01T00:00:00Z".to_string(),
            attestor_key_ref: "k".to_string(),
        }
    }

    /// Handles claim.
    fn claim() -> RewardClaim {
        RewardClaim {
            reward_claim_id: "claim-1".to_string(),
            actor_id: "actor-1".to_string(),
            action_type: "publish".to_string(),
            event_time_window: "2026-01-01T00:00:00Z/2026-01-01T01:00:00Z".to_string(),
            evidence_refs: vec!["publish://1".to_string()],
            proof_log_entry_refs: vec!["tx-1".to_string()],
            quality_score_ref: None,
            payout_recipient: "wallet-1".to_string(),
            payout_policy_id: "policy-1".to_string(),
        }
    }

    /// Handles publish.
    fn publish() -> PublishEnvelope {
        let mut p = PublishEnvelope {
            schema_version: "1.0.0".to_string(),
            envelope_type: "publish".to_string(),
            issued_at: "2026-01-01T00:00:00Z".to_string(),
            subject_id: "publish-1".to_string(),
            dataset_id: "sst".to_string(),
            dataset_version: "v1".to_string(),
            input_refs: vec!["obj://in".to_string()],
            output_refs: vec!["obj://out".to_string()],
            published_artifacts: vec![crate::checkpoint::CheckpointArtifact {
                artifact_id: "artifact-1".to_string(),
                content_root_hash: "content-root".to_string(),
                content_descriptor_ref: None,
                content_descriptor_hash: None,
                media_type: "application/vnd+zarr".to_string(),
            }],
            primary_artifact_id: "artifact-1".to_string(),
            checkpoint_manifest_ref: "checkpoint://1".to_string(),
            checkpoint_manifest_hash: "checkpoint-hash".to_string(),
            checkpoint_id: "checkpoint-1".to_string(),
            checkpoint_log_root_hash: "log-root".to_string(),
            lineage_refs: vec!["capture://1".to_string()],
            verification_policy_id: "verify-default".to_string(),
            attestations: vec![],
            key_id: "key-1".to_string(),
            stac_refs: vec![],
            reward_context_ref: None,
            reward_context_hash: None,
            provenance_start_mode: "transport_capture".to_string(),
            bootstrap_origin_label: None,
            reward_eligible: false,
        };
        let payload = p.canonical_signed_bytes();
        const TEST_SIGNING_KEY_HEX: &str =
            "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f9a4f5762b76f70f18";
        let attestation =
            make_attestation("s", TEST_SIGNING_KEY_HEX, "2026-01-01T00:00:00Z", &payload)
                .expect("build attestation");
        p.key_id = attestation.key_id.clone();
        p.attestations = vec![attestation];
        p
    }

    /// Handles valid proof-log commitment for publish.
    fn valid_proof_log_commitment_for_publish() -> ProofLogCommitment {
        crate::proof_log::build_proof_log_commitment(
            &publish(),
            "2026-01-01T00:00:00Z",
            "tx-1",
            "k",
        )
    }

    /// Tests that build reward claim requires verified report and known proof-log commitment.
    #[test]
    fn build_reward_claim_requires_verified_report_and_known_proof_log_commitment() {
        let built = build_reward_claim(
            claim(),
            &report_ok(),
            &publish(),
            &[valid_proof_log_commitment_for_publish()],
        )
        .expect("claim should build");
        assert_eq!(built.reward_claim_id, "claim-1");
    }

    /// Tests that build reward claim fails for unknown proof-log commitment.
    #[test]
    fn build_reward_claim_fails_for_unknown_proof_log_commitment() {
        let err =
            build_reward_claim(claim(), &report_ok(), &publish(), &[]).expect_err("must fail");
        assert!(err.to_string().contains("unknown proof log"));
    }

    /// Tests that build reward claim fails on invalid proof-log linkage.
    #[test]
    fn build_reward_claim_fails_on_invalid_proof_log_linkage() {
        let mut bad_commitment = proof_log_commitment();
        bad_commitment.entry_id = "tx-1".to_string();
        let err = build_reward_claim(claim(), &report_ok(), &publish(), &[bad_commitment])
            .expect_err("must fail");
        assert!(err.to_string().contains("invalid proof log linkage"));
    }

    /// Tests that settle reward claim is deterministic for same inputs.
    #[test]
    fn settle_reward_claim_is_deterministic_for_same_inputs() {
        let c = claim();
        let domain = SettlementDomain {
            chain_id: "solana-mainnet".to_string(),
            settlement_window: "2026-W01".to_string(),
            policy_id: "policy-1".to_string(),
            schema_version: "v1".to_string(),
            nonce: "nonce-1".to_string(),
        };
        let a = settle_reward_claim(&c, "2026-01-01T02:00:00Z", &domain);
        let b = settle_reward_claim(&c, "2026-01-01T02:00:00Z", &domain);
        assert_eq!(a.settlement_hash, b.settlement_hash);
    }

    /// Tests that settle reward claim hash changes across domains.
    #[test]
    fn settle_reward_claim_hash_changes_across_domains() {
        let c = claim();
        let base = SettlementDomain {
            chain_id: "solana-mainnet".to_string(),
            settlement_window: "2026-W01".to_string(),
            policy_id: "policy-1".to_string(),
            schema_version: "v1".to_string(),
            nonce: "nonce-1".to_string(),
        };
        let mut changed = base.clone();
        changed.nonce = "nonce-2".to_string();

        let a = settle_reward_claim(&c, "2026-01-01T02:00:00Z", &base);
        let b = settle_reward_claim(&c, "2026-01-01T02:00:00Z", &changed);
        assert_ne!(a.settlement_hash, b.settlement_hash);
    }
}