use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentProof {
pub mint_url: String,
pub swap_response_signature: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CompletionReceipt {
pub lease_id: String,
pub provider_npub: String,
pub consumer_npub: String,
pub duration_paid: u64,
pub duration_delivered: u64,
pub success_flag: f32,
pub payment_proof: PaymentProof,
pub version: u8,
pub consumer_signature: Option<String>,
pub provider_co_signature: Option<String>,
pub completed_at: u64,
}
#[derive(Debug, Clone, Copy)]
pub struct SybilHeuristics {
pub min_consumer_history_secs: u64,
pub max_same_counterparty_share: f32,
}
impl Default for SybilHeuristics {
fn default() -> Self {
Self {
min_consumer_history_secs: 30 * 24 * 3600,
max_same_counterparty_share: 0.20,
}
}
}
#[derive(Debug, Clone)]
pub struct ConsumerProfile {
pub npub: String,
pub first_seen: u64,
}
fn receipt_well_formed(r: &CompletionReceipt) -> bool {
r.consumer_signature.is_some()
&& r.provider_co_signature.is_some()
&& r.success_flag >= 0.0
&& r.success_flag <= 1.0
&& r.version > 0
}
pub fn score_provider<S, P>(
provider_npub: &str,
receipts: &[CompletionReceipt],
consumers: &HashMap<String, ConsumerProfile>,
now: u64,
heuristics: &SybilHeuristics,
verify_signatures: S,
verify_payment_proof: P,
) -> f32
where
S: Fn(&CompletionReceipt) -> bool,
P: Fn(&CompletionReceipt) -> bool,
{
let mut per_consumer_total: HashMap<&str, u32> = HashMap::new();
let mut per_consumer_for_provider: HashMap<&str, u32> = HashMap::new();
for r in receipts {
if !receipt_well_formed(r) {
continue;
}
let cons = r.consumer_npub.as_str();
*per_consumer_total.entry(cons).or_insert(0) += 1;
if r.provider_npub == provider_npub {
*per_consumer_for_provider.entry(cons).or_insert(0) += 1;
}
}
let mut weighted_sum = 0.0f32;
for r in receipts {
if r.provider_npub != provider_npub {
continue;
}
if !receipt_well_formed(r) {
continue;
}
if !verify_signatures(r) {
continue;
}
if !verify_payment_proof(r) {
continue;
}
let Some(profile) = consumers.get(&r.consumer_npub) else {
continue;
};
let consumer_age = now.saturating_sub(profile.first_seen);
if consumer_age < heuristics.min_consumer_history_secs {
continue;
}
let total = *per_consumer_total
.get(r.consumer_npub.as_str())
.unwrap_or(&0);
let same = *per_consumer_for_provider
.get(r.consumer_npub.as_str())
.unwrap_or(&0);
if total == 0 {
continue;
}
let share = same as f32 / total as f32;
let weight = if share > heuristics.max_same_counterparty_share {
heuristics.max_same_counterparty_share / share
} else {
1.0
};
weighted_sum += r.success_flag * weight;
}
weighted_sum
}
#[cfg(test)]
mod tests {
use super::*;
fn proof() -> PaymentProof {
PaymentProof {
mint_url: "https://mint.example".to_string(),
swap_response_signature: "deadbeef".to_string(),
}
}
pub(super) fn signed_receipt(
lease_id: &str,
provider: &str,
consumer: &str,
success: f32,
) -> CompletionReceipt {
CompletionReceipt {
lease_id: lease_id.to_string(),
provider_npub: provider.to_string(),
consumer_npub: consumer.to_string(),
duration_paid: 3600,
duration_delivered: 3600,
success_flag: success,
payment_proof: proof(),
version: 1,
consumer_signature: Some("c-sig".to_string()),
provider_co_signature: Some("p-sig".to_string()),
completed_at: 1_700_000_000,
}
}
fn consumer(npub: &str, first_seen: u64) -> ConsumerProfile {
ConsumerProfile {
npub: npub.to_string(),
first_seen,
}
}
fn always_valid(_r: &CompletionReceipt) -> bool {
true
}
#[test]
fn single_consumer_with_single_provider_is_capped_to_share() {
let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
let mut consumers = HashMap::new();
consumers.insert(
"C".to_string(),
consumer("C", 1_700_000_000 - 60 * 24 * 3600),
);
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
always_valid,
);
assert!((score - 0.20).abs() < 1e-6, "score = {}", score);
}
#[test]
fn diversified_consumers_each_contributing_one_receipt_sum() {
let mut receipts = Vec::new();
let mut consumers = HashMap::new();
for i in 0..5 {
let c = format!("C{}", i);
receipts.push(signed_receipt(&format!("l{}", i), "P", &c, 1.0));
consumers.insert(c.clone(), consumer(&c, 1_700_000_000 - 60 * 24 * 3600));
}
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
always_valid,
);
assert!((score - 1.0).abs() < 1e-4, "score = {}", score);
}
#[test]
fn missing_provider_co_signature_drops_receipt() {
let mut r = signed_receipt("l1", "P", "C", 1.0);
r.provider_co_signature = None;
let mut consumers = HashMap::new();
consumers.insert(
"C".to_string(),
consumer("C", 1_700_000_000 - 60 * 24 * 3600),
);
let score = score_provider(
"P",
&[r],
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
always_valid,
);
assert_eq!(score, 0.0);
}
#[test]
fn signature_verification_failure_drops_receipt() {
let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
let mut consumers = HashMap::new();
consumers.insert(
"C".to_string(),
consumer("C", 1_700_000_000 - 60 * 24 * 3600),
);
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
|_| false, always_valid,
);
assert_eq!(score, 0.0);
}
#[test]
fn payment_proof_failure_drops_receipt() {
let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
let mut consumers = HashMap::new();
consumers.insert(
"C".to_string(),
consumer("C", 1_700_000_000 - 60 * 24 * 3600),
);
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
|_| false, );
assert_eq!(score, 0.0);
}
#[test]
fn fresh_consumer_under_min_history_does_not_count() {
let receipts = vec![signed_receipt("l1", "P", "Cnew", 1.0)];
let mut consumers = HashMap::new();
consumers.insert("Cnew".to_string(), consumer("Cnew", 1_700_000_000 - 86400));
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
always_valid,
);
assert_eq!(score, 0.0);
}
#[test]
fn same_counterparty_cap_caps_contribution() {
let mut receipts = Vec::new();
for i in 0..9 {
receipts.push(signed_receipt(&format!("lp{}", i), "P", "C", 1.0));
}
receipts.push(signed_receipt("lq", "Q", "C", 1.0));
let mut consumers = HashMap::new();
consumers.insert(
"C".to_string(),
consumer("C", 1_700_000_000 - 60 * 24 * 3600),
);
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&SybilHeuristics::default(),
always_valid,
always_valid,
);
let expected = 9.0 * (0.20 / 0.90);
assert!(
(score - expected).abs() < 1e-4,
"score should be capped near {} (got {})",
expected,
score
);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn single_consumer_cannot_exceed_share_cap(
same_count in 1u32..200,
other_count in 0u32..200,
) {
let consumer_npub = "C".to_string();
let mut receipts = Vec::new();
for i in 0..same_count {
receipts.push(super::tests::signed_receipt(
&format!("p{}", i),
"P",
&consumer_npub,
1.0,
));
}
for i in 0..other_count {
receipts.push(super::tests::signed_receipt(
&format!("q{}", i),
"Q",
&consumer_npub,
1.0,
));
}
let mut consumers = HashMap::new();
consumers.insert(
consumer_npub.clone(),
ConsumerProfile {
npub: consumer_npub.clone(),
first_seen: 1_700_000_000 - 60 * 24 * 3600,
},
);
let h = SybilHeuristics::default();
let score = score_provider(
"P",
&receipts,
&consumers,
1_700_000_000,
&h,
|_| true,
|_| true,
);
let total = (same_count + other_count) as f32;
let cap = h.max_same_counterparty_share * total;
prop_assert!(
score <= cap + 1e-3,
"score {} exceeds Sybil cap {}",
score,
cap
);
}
}
}