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,
}
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)
}
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};
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![],
}
}
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(),
}
}
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(),
}
}
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
}
fn valid_proof_log_commitment_for_publish() -> ProofLogCommitment {
crate::proof_log::build_proof_log_commitment(
&publish(),
"2026-01-01T00:00:00Z",
"tx-1",
"k",
)
}
#[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");
}
#[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"));
}
#[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"));
}
#[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);
}
#[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);
}
}