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}