Skip to main content

paygress/
reputation.rs

1// Signed completion receipts and Sybil-resistant scoring (Unit 10
2// of the 12-month plan,
3// docs/plans/2026-04-26-001-feat-paygress-12mo-vision-plan.md).
4//
5// Receipts that contribute to a provider's reputation must be:
6//   1. Co-signed by both consumer and provider.
7//   2. Bound to a verifiable Cashu spend proof (a swap-response
8//      signature from the mint, captured on the provider side at
9//      the moment of redemption — Unit 1 produces this).
10//   3. Weighted to resist Sybil amplification: a consumer needs
11//      enough history before their receipts count, and any single
12//      consumer-provider pair is capped at 20% of the consumer's
13//      receipt volume.
14//
15// This module owns the **scoring logic**. The Nostr event publish
16// path and the provider co-sign flow are wired in follow-up units
17// (a per-event `KIND_COMPLETION_RECEIPT = 38385` parameterized
18// replaceable; provider-side co-sign on lease completion).
19
20use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23
24/// The Cashu spend proof carried by a receipt. Captured by the
25/// provider at redemption (Unit 1) and pasted verbatim into the
26/// receipt the provider co-signs. Aggregators verify this against
27/// the mint's published keys before counting the receipt.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct PaymentProof {
30    /// URL of the mint that issued the swap.
31    pub mint_url: String,
32    /// Signature over the swap response by the mint's keys (or a
33    /// hash thereof — exact bytes TBD by mint capabilities).
34    pub swap_response_signature: String,
35}
36
37/// Co-signed completion receipt. The consumer signs the
38/// canonicalized JSON of `(lease_id, provider_npub, consumer_npub,
39/// duration_paid, duration_delivered, success_flag, payment_proof,
40/// version)`; the provider returns a `provider_co_signature` over
41/// the same bytes; the receipt event carries both.
42///
43/// Receipts missing either signature do not contribute to score
44/// (see `score_provider`).
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct CompletionReceipt {
47    pub lease_id: String,
48    pub provider_npub: String,
49    pub consumer_npub: String,
50    /// Seconds the consumer paid for.
51    pub duration_paid: u64,
52    /// Seconds the workload was actually live (provider-reported,
53    /// cross-checkable against heartbeat history from Unit 4).
54    pub duration_delivered: u64,
55    /// 1.0 = success, 0.0 = failure. Floats so future units can
56    /// surface partial-credit cases (e.g. lease delivered but with
57    /// SLA violations).
58    pub success_flag: f32,
59    pub payment_proof: PaymentProof,
60    pub version: u8,
61    /// Schnorr signature over the canonical content by the
62    /// consumer's Nostr key. None means "consumer hasn't signed",
63    /// which is invalid for scoring.
64    pub consumer_signature: Option<String>,
65    /// Schnorr signature over the same content by the provider's
66    /// Nostr key. None means "provider hasn't co-signed".
67    pub provider_co_signature: Option<String>,
68    /// Unix timestamp (provider-stamped) at which this receipt was
69    /// minted. Aggregators can window by this.
70    pub completed_at: u64,
71}
72
73/// Heuristics the scoring function uses to defeat Sybil
74/// amplification. Operator-tunable via the observatory config.
75#[derive(Debug, Clone, Copy)]
76pub struct SybilHeuristics {
77    /// Receipts from consumers younger than this don't count.
78    pub min_consumer_history_secs: u64,
79    /// Cap on the share of a single consumer's receipts that can
80    /// be directed at any one provider before excess is weighted
81    /// to zero.
82    pub max_same_counterparty_share: f32,
83}
84
85impl Default for SybilHeuristics {
86    fn default() -> Self {
87        Self {
88            // 30 days. Plan §Unit 10. Anti-bootstrap-fakery: a
89            // brand-new consumer can't single-handedly score a
90            // brand-new provider.
91            min_consumer_history_secs: 30 * 24 * 3600,
92            // 20% per the plan's score function. Receipts past
93            // this share are weighted to zero.
94            max_same_counterparty_share: 0.20,
95        }
96    }
97}
98
99/// Per-consumer metadata the scoring function consults. Real
100/// observatory builds this from the consumer's first-seen Nostr
101/// activity; tests can stub it directly.
102#[derive(Debug, Clone)]
103pub struct ConsumerProfile {
104    pub npub: String,
105    /// Unix timestamp of the consumer's earliest known activity.
106    pub first_seen: u64,
107}
108
109/// Receipt validity check. A `false` here means the receipt
110/// MUST NOT contribute to score. The signature verification itself
111/// is delegated to `verify_signatures` so the scoring function is
112/// pure (no crypto side-effects); callers can supply a stub
113/// verifier in tests.
114fn receipt_well_formed(r: &CompletionReceipt) -> bool {
115    r.consumer_signature.is_some()
116        && r.provider_co_signature.is_some()
117        && r.success_flag >= 0.0
118        && r.success_flag <= 1.0
119        && r.version > 0
120}
121
122/// Score a single provider against the receipt set in `receipts`.
123/// Returns a non-negative score; magnitude is the sum of weighted
124/// success flags from receipts that survive every filter.
125///
126/// Filters applied (in order, short-circuiting):
127///   - well-formed (both signatures present, version > 0).
128///   - signature verification (`verify_signatures`).
129///   - payment-proof verification (`verify_payment_proof`).
130///   - consumer history >= `heuristics.min_consumer_history_secs`.
131///   - per-consumer Sybil cap on share of receipts directed at
132///     this provider.
133///
134/// `verify_signatures` and `verify_payment_proof` are passed as
135/// closures so tests can stub them (real implementations call into
136/// nostr-sdk Schnorr verification and the cdk mint key store
137/// respectively).
138pub fn score_provider<S, P>(
139    provider_npub: &str,
140    receipts: &[CompletionReceipt],
141    consumers: &HashMap<String, ConsumerProfile>,
142    now: u64,
143    heuristics: &SybilHeuristics,
144    verify_signatures: S,
145    verify_payment_proof: P,
146) -> f32
147where
148    S: Fn(&CompletionReceipt) -> bool,
149    P: Fn(&CompletionReceipt) -> bool,
150{
151    // First pass: pre-count each consumer's total valid receipts so
152    // we can apply the Sybil cap on a per-consumer basis. We
153    // pre-filter on cheap predicates only; expensive crypto checks
154    // are deferred to the second pass for the receipts we're
155    // actually about to count toward this provider.
156    let mut per_consumer_total: HashMap<&str, u32> = HashMap::new();
157    let mut per_consumer_for_provider: HashMap<&str, u32> = HashMap::new();
158    for r in receipts {
159        if !receipt_well_formed(r) {
160            continue;
161        }
162        let cons = r.consumer_npub.as_str();
163        *per_consumer_total.entry(cons).or_insert(0) += 1;
164        if r.provider_npub == provider_npub {
165            *per_consumer_for_provider.entry(cons).or_insert(0) += 1;
166        }
167    }
168
169    let mut weighted_sum = 0.0f32;
170    for r in receipts {
171        if r.provider_npub != provider_npub {
172            continue;
173        }
174        if !receipt_well_formed(r) {
175            continue;
176        }
177        if !verify_signatures(r) {
178            continue;
179        }
180        if !verify_payment_proof(r) {
181            continue;
182        }
183
184        // Consumer history gate.
185        let Some(profile) = consumers.get(&r.consumer_npub) else {
186            continue;
187        };
188        let consumer_age = now.saturating_sub(profile.first_seen);
189        if consumer_age < heuristics.min_consumer_history_secs {
190            continue;
191        }
192
193        // Sybil cap. If this consumer has directed > max_share of
194        // their receipts at this provider, excess is weighted to
195        // zero so the share rounds back down to max_share.
196        let total = *per_consumer_total
197            .get(r.consumer_npub.as_str())
198            .unwrap_or(&0);
199        let same = *per_consumer_for_provider
200            .get(r.consumer_npub.as_str())
201            .unwrap_or(&0);
202        if total == 0 {
203            continue;
204        }
205        let share = same as f32 / total as f32;
206        let weight = if share > heuristics.max_same_counterparty_share {
207            // Cap weight so the *effective* contribution from this
208            // consumer to this provider equals the cap.
209            heuristics.max_same_counterparty_share / share
210        } else {
211            1.0
212        };
213
214        weighted_sum += r.success_flag * weight;
215    }
216
217    weighted_sum
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn proof() -> PaymentProof {
225        PaymentProof {
226            mint_url: "https://mint.example".to_string(),
227            swap_response_signature: "deadbeef".to_string(),
228        }
229    }
230
231    pub(super) fn signed_receipt(
232        lease_id: &str,
233        provider: &str,
234        consumer: &str,
235        success: f32,
236    ) -> CompletionReceipt {
237        CompletionReceipt {
238            lease_id: lease_id.to_string(),
239            provider_npub: provider.to_string(),
240            consumer_npub: consumer.to_string(),
241            duration_paid: 3600,
242            duration_delivered: 3600,
243            success_flag: success,
244            payment_proof: proof(),
245            version: 1,
246            consumer_signature: Some("c-sig".to_string()),
247            provider_co_signature: Some("p-sig".to_string()),
248            completed_at: 1_700_000_000,
249        }
250    }
251
252    fn consumer(npub: &str, first_seen: u64) -> ConsumerProfile {
253        ConsumerProfile {
254            npub: npub.to_string(),
255            first_seen,
256        }
257    }
258
259    fn always_valid(_r: &CompletionReceipt) -> bool {
260        true
261    }
262
263    #[test]
264    fn single_consumer_with_single_provider_is_capped_to_share() {
265        // The Sybil cap is share-based: a consumer whose 100% of
266        // receipts go to one provider can only contribute
267        // `max_share` (= 20% by default), no matter how many
268        // receipts they file. This is the intended floor — a lone
269        // consumer cannot fully credit a lone provider.
270        let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
271        let mut consumers = HashMap::new();
272        consumers.insert(
273            "C".to_string(),
274            consumer("C", 1_700_000_000 - 60 * 24 * 3600),
275        );
276        let score = score_provider(
277            "P",
278            &receipts,
279            &consumers,
280            1_700_000_000,
281            &SybilHeuristics::default(),
282            always_valid,
283            always_valid,
284        );
285        assert!((score - 0.20).abs() < 1e-6, "score = {}", score);
286    }
287
288    #[test]
289    fn diversified_consumers_each_contributing_one_receipt_sum() {
290        // Five distinct consumers, each filing exactly one receipt
291        // against P. Each is capped to 0.20; total = 1.0.
292        let mut receipts = Vec::new();
293        let mut consumers = HashMap::new();
294        for i in 0..5 {
295            let c = format!("C{}", i);
296            receipts.push(signed_receipt(&format!("l{}", i), "P", &c, 1.0));
297            consumers.insert(c.clone(), consumer(&c, 1_700_000_000 - 60 * 24 * 3600));
298        }
299        let score = score_provider(
300            "P",
301            &receipts,
302            &consumers,
303            1_700_000_000,
304            &SybilHeuristics::default(),
305            always_valid,
306            always_valid,
307        );
308        assert!((score - 1.0).abs() < 1e-4, "score = {}", score);
309    }
310
311    #[test]
312    fn missing_provider_co_signature_drops_receipt() {
313        let mut r = signed_receipt("l1", "P", "C", 1.0);
314        r.provider_co_signature = None;
315        let mut consumers = HashMap::new();
316        consumers.insert(
317            "C".to_string(),
318            consumer("C", 1_700_000_000 - 60 * 24 * 3600),
319        );
320        let score = score_provider(
321            "P",
322            &[r],
323            &consumers,
324            1_700_000_000,
325            &SybilHeuristics::default(),
326            always_valid,
327            always_valid,
328        );
329        assert_eq!(score, 0.0);
330    }
331
332    #[test]
333    fn signature_verification_failure_drops_receipt() {
334        let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
335        let mut consumers = HashMap::new();
336        consumers.insert(
337            "C".to_string(),
338            consumer("C", 1_700_000_000 - 60 * 24 * 3600),
339        );
340        let score = score_provider(
341            "P",
342            &receipts,
343            &consumers,
344            1_700_000_000,
345            &SybilHeuristics::default(),
346            |_| false, // verify_signatures rejects everything
347            always_valid,
348        );
349        assert_eq!(score, 0.0);
350    }
351
352    #[test]
353    fn payment_proof_failure_drops_receipt() {
354        let receipts = vec![signed_receipt("l1", "P", "C", 1.0)];
355        let mut consumers = HashMap::new();
356        consumers.insert(
357            "C".to_string(),
358            consumer("C", 1_700_000_000 - 60 * 24 * 3600),
359        );
360        let score = score_provider(
361            "P",
362            &receipts,
363            &consumers,
364            1_700_000_000,
365            &SybilHeuristics::default(),
366            always_valid,
367            |_| false, // verify_payment_proof rejects everything
368        );
369        assert_eq!(score, 0.0);
370    }
371
372    #[test]
373    fn fresh_consumer_under_min_history_does_not_count() {
374        let receipts = vec![signed_receipt("l1", "P", "Cnew", 1.0)];
375        let mut consumers = HashMap::new();
376        // Only 1 day of history < default 30-day floor.
377        consumers.insert("Cnew".to_string(), consumer("Cnew", 1_700_000_000 - 86400));
378        let score = score_provider(
379            "P",
380            &receipts,
381            &consumers,
382            1_700_000_000,
383            &SybilHeuristics::default(),
384            always_valid,
385            always_valid,
386        );
387        assert_eq!(score, 0.0);
388    }
389
390    #[test]
391    fn same_counterparty_cap_caps_contribution() {
392        // Consumer has 10 total receipts; 9 of them are against
393        // provider P. Per the 20% cap, P's effective contribution
394        // from this consumer is capped at 20% × 10 = 2.0, not 9.0.
395        let mut receipts = Vec::new();
396        for i in 0..9 {
397            receipts.push(signed_receipt(&format!("lp{}", i), "P", "C", 1.0));
398        }
399        // One receipt against a different provider so total = 10.
400        receipts.push(signed_receipt("lq", "Q", "C", 1.0));
401        let mut consumers = HashMap::new();
402        consumers.insert(
403            "C".to_string(),
404            consumer("C", 1_700_000_000 - 60 * 24 * 3600),
405        );
406
407        let score = score_provider(
408            "P",
409            &receipts,
410            &consumers,
411            1_700_000_000,
412            &SybilHeuristics::default(),
413            always_valid,
414            always_valid,
415        );
416
417        // 9 receipts × (0.20 / 0.90) ≈ 2.0
418        let expected = 9.0 * (0.20 / 0.90);
419        assert!(
420            (score - expected).abs() < 1e-4,
421            "score should be capped near {} (got {})",
422            expected,
423            score
424        );
425    }
426}
427
428#[cfg(test)]
429mod proptests {
430    use super::*;
431    use proptest::prelude::*;
432
433    proptest! {
434        /// Sybil bound: across any random set of receipts where a
435        /// single consumer fires at most N receipts at one provider
436        /// out of M total, that consumer can never push the score
437        /// past `max_share * M`. (The cap applies per-consumer; the
438        /// invariant we check is the per-consumer ceiling.)
439        #[test]
440        fn single_consumer_cannot_exceed_share_cap(
441            same_count in 1u32..200,
442            other_count in 0u32..200,
443        ) {
444            let consumer_npub = "C".to_string();
445            let mut receipts = Vec::new();
446            for i in 0..same_count {
447                receipts.push(super::tests::signed_receipt(
448                    &format!("p{}", i),
449                    "P",
450                    &consumer_npub,
451                    1.0,
452                ));
453            }
454            for i in 0..other_count {
455                receipts.push(super::tests::signed_receipt(
456                    &format!("q{}", i),
457                    "Q",
458                    &consumer_npub,
459                    1.0,
460                ));
461            }
462            let mut consumers = HashMap::new();
463            consumers.insert(
464                consumer_npub.clone(),
465                ConsumerProfile {
466                    npub: consumer_npub.clone(),
467                    first_seen: 1_700_000_000 - 60 * 24 * 3600,
468                },
469            );
470            let h = SybilHeuristics::default();
471            let score = score_provider(
472                "P",
473                &receipts,
474                &consumers,
475                1_700_000_000,
476                &h,
477                |_| true,
478                |_| true,
479            );
480            let total = (same_count + other_count) as f32;
481            let cap = h.max_same_counterparty_share * total;
482            // Allow tiny float epsilon. score should never exceed
483            // the cap (when same_count is the only contribution).
484            prop_assert!(
485                score <= cap + 1e-3,
486                "score {} exceeds Sybil cap {}",
487                score,
488                cap
489            );
490        }
491    }
492}