Skip to main content

mostro_core/
reputation.rs

1//! Reputation attestations (kind `38388`).
2//!
3//! An attestation is a Nostr event an *issuer* (a Mostro instance or
4//! lnp2pBot) signs to state the reputation a user earned there, addressed to
5//! one identity on a destination instance that will import it. It is never
6//! published to relays: it travels inside the `reputation-exported` reply and
7//! the `import-reputation` request. A rebind authorisation shares the kind and
8//! moves the issuer's binding of a source account to a new identity.
9//!
10//! This module builds both events and parses them, applying every rule the
11//! event itself carries: signature, kind, document tag, tag formats and the
12//! clock. What depends on the receiver — whether the issuer is trusted, whether
13//! it is the receiver's own key, whether the attestation names the identity
14//! the transport proved, whether the rebind is signed by the bound identity —
15//! stays with the caller.
16//!
17//! The rules are those of the protocol's
18//! [reputation attestation](https://mostro.network/protocol/reputation_attestation.html)
19//! chapter, and the tests run its test vectors.
20
21use std::collections::HashMap;
22use std::fmt;
23
24use nostr_sdk::prelude::*;
25
26use crate::error::CantDoReason;
27use crate::prelude::NOSTR_REPUTATION_ATTESTATION_KIND;
28
29/// Lifetime an issuer gives an attestation, and the default cap a
30/// destination enforces: 7 days.
31pub const ATTESTATION_LIFETIME_SECS: u64 = 7 * SECONDS_PER_DAY;
32/// Clock skew tolerated on both `created_at` and `expiration`.
33pub const MAX_CLOCK_SKEW_SECS: u64 = 300;
34/// Longest a rebind authorisation may live.
35pub const REBIND_MAX_LIFETIME_SECS: u64 = 3_600;
36/// Fewest ratings received an attestation can carry: the eligibility floor.
37pub const MIN_ATTESTED_REVIEWS: u32 = 5;
38/// Earliest first-trade date an attestation can carry: 2020-01-01, before
39/// any issuer existed.
40pub const MIN_ATTESTED_SINCE: u64 = 1_577_836_800;
41/// Lowest and highest `rating` an attestation can carry, in hundredths.
42const RATING_RANGE: std::ops::RangeInclusive<u16> = 100..=500;
43const SUBJECT_MAX_LEN: usize = 64;
44const SECONDS_PER_DAY: u64 = 86_400;
45const ATTESTATION_DOCUMENT: &str = "reputation-attestation";
46const REBIND_DOCUMENT: &str = "reputation-rebind";
47
48/// Why an attestation or a rebind authorisation was refused.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum AttestationError {
51    /// The text is not a Nostr event.
52    Malformed,
53    /// The event id does not match its content, or the signature is invalid.
54    InvalidSignature,
55    /// The event is not of kind `38388`.
56    WrongKind,
57    /// The `z` tag is missing or names another document.
58    WrongDocument,
59    /// A required tag is absent.
60    MissingTag(&'static str),
61    /// A tag that must appear once appears more than once.
62    RepeatedTag(&'static str),
63    /// A tag value breaks its format or range.
64    InvalidTag(&'static str),
65    /// `created_at` is later than the clock allows.
66    NotYetValid,
67    /// `expiration` has passed.
68    Expired,
69    /// `expiration - created_at` exceeds the allowed lifetime.
70    LifetimeTooLong,
71    /// An argument passed to a builder would produce an event that does not
72    /// parse.
73    InvalidInput(&'static str),
74}
75
76impl fmt::Display for AttestationError {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Malformed => write!(f, "not a Nostr event"),
80            Self::InvalidSignature => write!(f, "invalid event id or signature"),
81            Self::WrongKind => write!(f, "not a kind {NOSTR_REPUTATION_ATTESTATION_KIND} event"),
82            Self::WrongDocument => write!(f, "missing or unexpected z tag"),
83            Self::MissingTag(t) => write!(f, "missing `{t}` tag"),
84            Self::RepeatedTag(t) => write!(f, "repeated `{t}` tag"),
85            Self::InvalidTag(t) => write!(f, "invalid `{t}` tag"),
86            Self::NotYetValid => write!(f, "created in the future"),
87            Self::Expired => write!(f, "expired"),
88            Self::LifetimeTooLong => write!(f, "lifetime exceeds the allowed maximum"),
89            Self::InvalidInput(what) => write!(f, "invalid {what}"),
90        }
91    }
92}
93
94impl std::error::Error for AttestationError {}
95
96impl AttestationError {
97    /// The `cant-do` reason a destination answers an attestation refused
98    /// with this error: `expired_reputation_attestation` when it expired,
99    /// `invalid_reputation_attestation` otherwise. A refused rebind
100    /// authorisation is always [`CantDoReason::InvalidReputationRebind`].
101    pub fn cant_do_reason(&self) -> CantDoReason {
102        match self {
103            Self::Expired => CantDoReason::ExpiredReputationAttestation,
104            _ => CantDoReason::InvalidReputationAttestation,
105        }
106    }
107}
108
109/// A parsed, verified reputation attestation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ReputationAttestation {
112    /// Event id, which identifies the attestation.
113    pub id: EventId,
114    /// Key the issuer signed with.
115    pub issuer: PublicKey,
116    /// Identity the attestation is addressed to (`p`).
117    pub destination: PublicKey,
118    /// The source account, opaque and stable within the issuer.
119    pub subject: String,
120    /// Ratings received on the source.
121    pub reviews: u32,
122    /// Average of those ratings, in hundredths (`487` is `4.87`).
123    pub rating_hundredths: u16,
124    /// First completed trade on the source, a UTC day start.
125    pub since: u64,
126    /// When the issuer signed it.
127    pub created_at: u64,
128    /// When it stops being redeemable.
129    pub expiration: u64,
130}
131
132impl ReputationAttestation {
133    /// Average rating as a number, `4.87` for `rating_hundredths == 487`.
134    ///
135    /// The division is correctly rounded, so this is the same double as
136    /// parsing the decimal string the event carries.
137    pub fn rating(&self) -> f64 {
138        f64::from(self.rating_hundredths) / 100.0
139    }
140
141    /// Average rating as the event writes it, with two decimals.
142    pub fn rating_text(&self) -> String {
143        format_rating(self.rating_hundredths)
144    }
145
146    /// Sign an attestation with the issuer's dedicated key.
147    ///
148    /// `average` is the issuer's internal average; it is rounded and clamped
149    /// as [`rating_hundredths`] does. The arguments are checked against the
150    /// rules a destination applies, so a successful build always parses.
151    /// `lifetime` is at most [`ATTESTATION_LIFETIME_SECS`], the default cap.
152    #[allow(clippy::too_many_arguments)]
153    pub fn build(
154        issuer: &Keys,
155        destination: &PublicKey,
156        subject: &str,
157        reviews: u32,
158        average: f64,
159        since: u64,
160        created_at: Timestamp,
161        lifetime: u64,
162    ) -> Result<Event, AttestationError> {
163        let created = created_at.as_secs();
164        if !valid_subject(subject) {
165            return Err(AttestationError::InvalidInput("subject"));
166        }
167        if reviews < MIN_ATTESTED_REVIEWS {
168            return Err(AttestationError::InvalidInput("reviews"));
169        }
170        let hundredths =
171            rating_hundredths(average).ok_or(AttestationError::InvalidInput("average"))?;
172        if !valid_since(since, created) {
173            return Err(AttestationError::InvalidInput("since"));
174        }
175        if lifetime == 0 || lifetime > ATTESTATION_LIFETIME_SECS {
176            return Err(AttestationError::InvalidInput("lifetime"));
177        }
178        let expiration = created
179            .checked_add(lifetime)
180            .ok_or(AttestationError::InvalidInput("lifetime"))?;
181        let tags = [
182            Tag::public_key(*destination),
183            custom_tag("subject", subject),
184            custom_tag("reviews", &reviews.to_string()),
185            custom_tag("rating", &format_rating(hundredths)),
186            custom_tag("since", &since.to_string()),
187            custom_tag("expiration", &expiration.to_string()),
188            custom_tag("z", ATTESTATION_DOCUMENT),
189        ];
190        sign(issuer, tags, created_at)
191    }
192
193    /// Parse and verify an attestation event.
194    ///
195    /// Applies every rule of the event: id and signature, kind, `z`, each tag
196    /// exactly once and in range, `created_at` no later than `now` plus the
197    /// skew, `expiration` no earlier than `now` minus the skew, and a lifetime
198    /// of at most `max_lifetime` seconds. The trust list, the receiver's own
199    /// issuer key and the identity match are the caller's.
200    pub fn parse(
201        event: &Event,
202        now: Timestamp,
203        max_lifetime: u64,
204    ) -> Result<Self, AttestationError> {
205        verify(event, ATTESTATION_DOCUMENT)?;
206        let tags = single_tags(
207            event,
208            &["p", "subject", "reviews", "rating", "since", "expiration"],
209        )?;
210        let destination = hex_key(tags["p"], "p")?;
211        let subject = tags["subject"];
212        if !valid_subject(subject) {
213            return Err(AttestationError::InvalidTag("subject"));
214        }
215        let reviews = decimal(tags["reviews"])
216            .and_then(|r| u32::try_from(r).ok())
217            .filter(|r| *r >= MIN_ATTESTED_REVIEWS)
218            .ok_or(AttestationError::InvalidTag("reviews"))?;
219        let rating_hundredths =
220            parse_rating(tags["rating"]).ok_or(AttestationError::InvalidTag("rating"))?;
221        let created_at = event.created_at.as_secs();
222        let since = decimal(tags["since"])
223            .filter(|s| valid_since(*s, created_at))
224            .ok_or(AttestationError::InvalidTag("since"))?;
225        let expiration = decimal(tags["expiration"])
226            .filter(|e| *e > created_at)
227            .ok_or(AttestationError::InvalidTag("expiration"))?;
228        check_clock(created_at, expiration, now, max_lifetime)?;
229        Ok(Self {
230            id: event.id,
231            issuer: event.pubkey,
232            destination,
233            subject: subject.to_string(),
234            reviews,
235            rating_hundredths,
236            since,
237            created_at,
238            expiration,
239        })
240    }
241
242    /// [`ReputationAttestation::parse`] on the JSON serialisation an
243    /// attestation travels in. Returns the event too, so a caller can keep
244    /// exactly what it received.
245    pub fn parse_json(
246        json: &str,
247        now: Timestamp,
248        max_lifetime: u64,
249    ) -> Result<(Self, Event), AttestationError> {
250        let event = Event::from_json(json).map_err(|_| AttestationError::Malformed)?;
251        Self::parse(&event, now, max_lifetime).map(|a| (a, event))
252    }
253}
254
255/// What a destination keeps of an import to be able to reverse it: the
256/// three figures it merged.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct ReputationImport {
259    /// Ratings received the import added.
260    pub reviews: u32,
261    /// Their average, in hundredths.
262    pub rating_hundredths: u16,
263    /// The first-trade date it carried.
264    pub since: u64,
265}
266
267impl ReputationImport {
268    /// Average rating as a number, the same double the merge used.
269    pub fn rating(&self) -> f64 {
270        f64::from(self.rating_hundredths) / 100.0
271    }
272}
273
274impl From<&ReputationAttestation> for ReputationImport {
275    fn from(a: &ReputationAttestation) -> Self {
276        Self {
277            reviews: a.reviews,
278            rating_hundredths: a.rating_hundredths,
279            since: a.since,
280        }
281    }
282}
283
284/// A parsed, verified rebind authorisation: the identity a source account is
285/// bound to at an issuer consents to moving the binding to a new identity.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ReputationRebind {
288    /// Event id.
289    pub id: EventId,
290    /// The identity that signed it, which must be the one currently bound.
291    pub bound_identity: PublicKey,
292    /// The identity the binding moves to (`p`).
293    pub new_identity: PublicKey,
294    /// The issuer key it is valid at (`issuer`).
295    pub issuer: PublicKey,
296    /// When it was signed.
297    pub created_at: u64,
298    /// When it stops being valid.
299    pub expiration: u64,
300}
301
302impl ReputationRebind {
303    /// Sign a rebind authorisation with the currently bound identity.
304    ///
305    /// `lifetime` is at most [`REBIND_MAX_LIFETIME_SECS`].
306    pub fn build(
307        bound_identity: &Keys,
308        issuer: &PublicKey,
309        new_identity: &PublicKey,
310        created_at: Timestamp,
311        lifetime: u64,
312    ) -> Result<Event, AttestationError> {
313        if lifetime == 0 || lifetime > REBIND_MAX_LIFETIME_SECS {
314            return Err(AttestationError::InvalidInput("lifetime"));
315        }
316        let expiration = created_at.as_secs() + lifetime;
317        let tags = [
318            Tag::public_key(*new_identity),
319            custom_tag("issuer", &issuer.to_hex()),
320            custom_tag("expiration", &expiration.to_string()),
321            custom_tag("z", REBIND_DOCUMENT),
322        ];
323        sign(bound_identity, tags, created_at)
324    }
325
326    /// Parse and verify a rebind authorisation.
327    ///
328    /// Whether the signer is the identity actually bound, whether `issuer`
329    /// is the receiver's own issuer key, and whether `p` is the destination
330    /// of the export request it travels in, is the caller's to check.
331    pub fn parse(event: &Event, now: Timestamp) -> Result<Self, AttestationError> {
332        verify(event, REBIND_DOCUMENT)?;
333        let tags = single_tags(event, &["p", "issuer", "expiration"])?;
334        let new_identity = hex_key(tags["p"], "p")?;
335        let issuer = hex_key(tags["issuer"], "issuer")?;
336        let created_at = event.created_at.as_secs();
337        let expiration = decimal(tags["expiration"])
338            .filter(|e| *e > created_at)
339            .ok_or(AttestationError::InvalidTag("expiration"))?;
340        check_clock(created_at, expiration, now, REBIND_MAX_LIFETIME_SECS)?;
341        Ok(Self {
342            id: event.id,
343            bound_identity: event.pubkey,
344            new_identity,
345            issuer,
346            created_at,
347            expiration,
348        })
349    }
350
351    /// [`ReputationRebind::parse`] on a JSON serialisation.
352    pub fn parse_json(json: &str, now: Timestamp) -> Result<Self, AttestationError> {
353        let event = Event::from_json(json).map_err(|_| AttestationError::Malformed)?;
354        Self::parse(&event, now)
355    }
356}
357
358/// The `rating` an issuer writes for an internal average, in hundredths:
359/// `clamp(round(average × 100), 100, 500)` in double precision, rounding half
360/// away from zero. `None` for a non-finite average.
361///
362/// Rounding the double, not a decimal string, is what keeps implementations
363/// in agreement: `4.895 × 100` is `489.49999999999994`, so `4.895` gives
364/// `4.89`.
365pub fn rating_hundredths(average: f64) -> Option<u16> {
366    if !average.is_finite() {
367        return None;
368    }
369    let (low, high) = (*RATING_RANGE.start(), *RATING_RANGE.end());
370    Some(
371        (average * 100.0)
372            .round()
373            .clamp(f64::from(low), f64::from(high)) as u16,
374    )
375}
376
377/// Write a rating in hundredths with exactly two decimals.
378pub fn format_rating(hundredths: u16) -> String {
379    format!("{}.{:02}", hundredths / 100, hundredths % 100)
380}
381
382fn custom_tag(name: &str, value: &str) -> Tag {
383    Tag::custom(name, [value])
384}
385
386fn sign<const N: usize>(
387    keys: &Keys,
388    tags: [Tag; N],
389    created_at: Timestamp,
390) -> Result<Event, AttestationError> {
391    EventBuilder::new(Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND), "")
392        .tags(tags)
393        .custom_created_at(created_at)
394        .finalize(keys)
395        .map_err(|_| AttestationError::InvalidInput("signing key"))
396}
397
398/// Id, signature, kind and `z`, in that order.
399fn verify(event: &Event, document: &str) -> Result<(), AttestationError> {
400    event
401        .verify()
402        .map_err(|_| AttestationError::InvalidSignature)?;
403    if event.kind != Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND) {
404        return Err(AttestationError::WrongKind);
405    }
406    let documents: Vec<&str> = values(event, "z").collect();
407    match documents.as_slice() {
408        [z] if *z == document => Ok(()),
409        _ => Err(AttestationError::WrongDocument),
410    }
411}
412
413/// The first value of every tag called `name`.
414fn values<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a str> {
415    event
416        .tags
417        .iter()
418        .filter_map(move |tag| match tag.as_slice() {
419            [kind, value, ..] if kind == name => Some(value.as_str()),
420            [kind] if kind == name => Some(""),
421            _ => None,
422        })
423}
424
425/// The value of each named tag, which must appear exactly once.
426fn single_tags<'a>(
427    event: &'a Event,
428    names: &[&'static str],
429) -> Result<HashMap<&'static str, &'a str>, AttestationError> {
430    let mut found = HashMap::new();
431    for name in names {
432        let mut all = values(event, name);
433        let value = all.next().ok_or(AttestationError::MissingTag(name))?;
434        if all.next().is_some() {
435            return Err(AttestationError::RepeatedTag(name));
436        }
437        found.insert(*name, value);
438    }
439    Ok(found)
440}
441
442fn hex_key(value: &str, tag: &'static str) -> Result<PublicKey, AttestationError> {
443    let lowercase_hex = value.len() == 64
444        && value
445            .bytes()
446            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
447    if !lowercase_hex {
448        return Err(AttestationError::InvalidTag(tag));
449    }
450    PublicKey::from_hex(value).map_err(|_| AttestationError::InvalidTag(tag))
451}
452
453/// A base-10 integer with no sign, leading zero or whitespace.
454fn decimal(value: &str) -> Option<u64> {
455    let digits = !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit());
456    let canonical = value == "0" || !value.starts_with('0');
457    (digits && canonical).then(|| value.parse().ok()).flatten()
458}
459
460/// `d.dd`, between `1.00` and `5.00`.
461fn parse_rating(value: &str) -> Option<u16> {
462    let bytes = value.as_bytes();
463    let shape = bytes.len() == 4
464        && bytes[1] == b'.'
465        && [bytes[0], bytes[2], bytes[3]]
466            .iter()
467            .all(u8::is_ascii_digit);
468    if !shape {
469        return None;
470    }
471    let digit = |i: usize| u16::from(bytes[i] - b'0');
472    Some(digit(0) * 100 + digit(2) * 10 + digit(3)).filter(|h| RATING_RANGE.contains(h))
473}
474
475fn valid_subject(subject: &str) -> bool {
476    (1..=SUBJECT_MAX_LEN).contains(&subject.len())
477        && subject
478            .bytes()
479            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
480}
481
482fn valid_since(since: u64, created_at: u64) -> bool {
483    since.is_multiple_of(SECONDS_PER_DAY) && since >= MIN_ATTESTED_SINCE && since <= created_at
484}
485
486fn check_clock(
487    created_at: u64,
488    expiration: u64,
489    now: Timestamp,
490    max_lifetime: u64,
491) -> Result<(), AttestationError> {
492    let now = now.as_secs();
493    if created_at > now.saturating_add(MAX_CLOCK_SKEW_SECS) {
494        return Err(AttestationError::NotYetValid);
495    }
496    if now > expiration.saturating_add(MAX_CLOCK_SKEW_SECS) {
497        return Err(AttestationError::Expired);
498    }
499    if expiration - created_at > max_lifetime {
500        return Err(AttestationError::LifetimeTooLong);
501    }
502    Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use serde_json::Value;
509
510    const VECTORS: &str = include_str!("../tests/vectors/reputation_v1.json");
511
512    /// Well-formed attestations that only the destination's context refuses.
513    const CONTEXT_REFUSALS: [&str; 3] = ["identity_mismatch", "untrusted_issuer", "own_issuer_key"];
514    /// Well-formed rebinds that only the issuer's context refuses.
515    const REBIND_CONTEXT_REFUSALS: [&str; 3] = [
516        "signed_by_other_identity",
517        "other_issuer",
518        "other_destination",
519    ];
520
521    fn vectors() -> Value {
522        serde_json::from_str(VECTORS).expect("vectors parse")
523    }
524
525    fn event(v: &Value) -> Event {
526        Event::from_json(v.to_string()).expect("vector event parses")
527    }
528
529    fn u64_at(v: &Value, key: &str) -> u64 {
530        v[key].as_u64().expect("number")
531    }
532
533    fn now(v: &Value) -> Timestamp {
534        Timestamp::from(u64_at(&v["context"], "now"))
535    }
536
537    fn max_lifetime(v: &Value) -> u64 {
538        u64_at(&v["context"], "max_lifetime")
539    }
540
541    fn keys(v: &Value, label: &str) -> Keys {
542        Keys::parse(v["secret_keys"][label].as_str().expect("key")).expect("valid key")
543    }
544
545    fn key(hex: &Value) -> PublicKey {
546        PublicKey::from_hex(hex.as_str().expect("hex")).expect("valid key")
547    }
548
549    #[test]
550    fn the_valid_attestation_parses_to_its_expected_fields() {
551        let v = vectors();
552        let valid = &v["attestation"]["valid"];
553        let expect = &valid["expect"];
554        let (parsed, event) = ReputationAttestation::parse_json(
555            valid["json"].as_str().expect("json"),
556            now(&v),
557            max_lifetime(&v),
558        )
559        .expect("valid attestation");
560        assert_eq!(event.id.to_hex(), valid["id"].as_str().expect("id"));
561        assert_eq!(parsed.id, event.id);
562        assert_eq!(parsed.issuer, key(&expect["issuer_key"]));
563        assert_eq!(parsed.destination, key(&expect["destination"]));
564        assert_eq!(parsed.subject, expect["subject"].as_str().expect("subject"));
565        assert_eq!(u64::from(parsed.reviews), u64_at(expect, "reviews"));
566        assert_eq!(
567            parsed.rating_text(),
568            expect["rating"].as_str().expect("rating")
569        );
570        assert_eq!(parsed.rating(), "4.87".parse::<f64>().expect("decimal"));
571        assert_eq!(parsed.since, u64_at(expect, "since"));
572        assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
573        assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
574    }
575
576    #[test]
577    fn every_accepted_edge_parses() {
578        let v = vectors();
579        for case in v["attestation"]["accepted"].as_array().expect("cases") {
580            let result =
581                ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
582            assert!(result.is_ok(), "{}: {result:?}", case["name"]);
583        }
584    }
585
586    #[test]
587    fn every_invalid_attestation_is_refused_by_the_event_or_left_to_the_caller() {
588        let v = vectors();
589        for case in v["attestation"]["invalid"].as_array().expect("cases") {
590            let name = case["name"].as_str().expect("name");
591            let result =
592                ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
593            if CONTEXT_REFUSALS.contains(&name) {
594                assert!(result.is_ok(), "{name} is well formed: {result:?}");
595                continue;
596            }
597            let err = result.expect_err(name);
598            let reason = serde_json::to_value(err.cant_do_reason()).expect("reason serialises");
599            assert_eq!(reason, case["reason"], "{name}: {err:?}");
600        }
601    }
602
603    #[test]
604    fn the_context_refusals_are_caught_by_the_checks_a_destination_adds() {
605        let v = vectors();
606        let ctx = &v["context"];
607        let identity = key(&ctx["proven_identity"]);
608        let own = key(&ctx["own_issuer_key"]);
609        let trusted: Vec<PublicKey> = ctx["trust_list"]
610            .as_array()
611            .expect("entries")
612            .iter()
613            .flat_map(|e| e["keys"].as_array().expect("keys").iter().map(key))
614            .collect();
615        for case in v["attestation"]["invalid"].as_array().expect("cases") {
616            let name = case["name"].as_str().expect("name");
617            if !CONTEXT_REFUSALS.contains(&name) {
618                continue;
619            }
620            let a = ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v))
621                .expect(name);
622            let refused =
623                !trusted.contains(&a.issuer) || a.issuer == own || a.destination != identity;
624            assert!(refused, "{name} must fail a context check");
625        }
626    }
627
628    #[test]
629    fn rating_rounds_half_away_from_zero_on_the_double() {
630        for case in vectors()["rating_rounding"].as_array().expect("cases") {
631            let average = case["average"].as_f64().expect("average");
632            let hundredths = rating_hundredths(average).expect("finite");
633            assert_eq!(
634                format_rating(hundredths),
635                case["rating"].as_str().expect("rating"),
636                "{average}"
637            );
638        }
639        assert_eq!(rating_hundredths(f64::NAN), None);
640        assert_eq!(rating_hundredths(f64::INFINITY), None);
641    }
642
643    #[test]
644    fn the_valid_rebind_parses_to_its_expected_fields() {
645        let v = vectors();
646        let rebind = &v["rebind"];
647        let expect = &rebind["valid"]["expect"];
648        let now = Timestamp::from(u64_at(&rebind["context"], "now"));
649        let parsed =
650            ReputationRebind::parse_json(rebind["valid"]["json"].as_str().expect("json"), now)
651                .expect("valid rebind");
652        assert_eq!(parsed.bound_identity, key(&expect["bound_identity"]));
653        assert_eq!(parsed.new_identity, key(&expect["new_identity"]));
654        assert_eq!(parsed.issuer, key(&expect["issuer"]));
655        assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
656        assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
657    }
658
659    #[test]
660    fn every_invalid_rebind_is_refused_by_the_event_or_by_the_issuers_checks() {
661        let v = vectors();
662        let rebind = &v["rebind"];
663        let ctx = &rebind["context"];
664        let now = Timestamp::from(u64_at(ctx, "now"));
665        let (bound, issuer) = (key(&ctx["bound_identity"]), key(&ctx["issuer_key"]));
666        let destination = key(&ctx["destination"]);
667        for case in rebind["invalid"].as_array().expect("cases") {
668            let name = case["name"].as_str().expect("name");
669            let result = ReputationRebind::parse(&event(&case["event"]), now);
670            if REBIND_CONTEXT_REFUSALS.contains(&name) {
671                let r = result.expect(name);
672                // Each case breaks exactly the field it is named after.
673                let mismatched = (
674                    r.bound_identity != bound,
675                    r.issuer != issuer,
676                    r.new_identity != destination,
677                );
678                let expected = match name {
679                    "signed_by_other_identity" => (true, false, false),
680                    "other_issuer" => (false, true, false),
681                    "other_destination" => (false, false, true),
682                    _ => unreachable!("{name}"),
683                };
684                assert_eq!(mismatched, expected, "{name}");
685            } else {
686                assert!(result.is_err(), "{name}: {result:?}");
687            }
688        }
689    }
690
691    #[test]
692    fn an_attestation_is_never_taken_for_a_rebind_or_the_reverse() {
693        let v = vectors();
694        let attestation = event(&v["attestation"]["valid"]["event"]);
695        assert_eq!(
696            ReputationRebind::parse(&attestation, now(&v)),
697            Err(AttestationError::WrongDocument)
698        );
699        let rebind = event(&v["rebind"]["valid"]["event"]);
700        let rebind_now = Timestamp::from(u64_at(&v["rebind"]["context"], "now"));
701        assert_eq!(
702            ReputationAttestation::parse(&rebind, rebind_now, max_lifetime(&v)),
703            Err(AttestationError::WrongDocument)
704        );
705    }
706
707    #[test]
708    fn a_built_attestation_parses_back_to_what_was_built() {
709        let v = vectors();
710        let issuer = keys(&v, "issuer-a");
711        let destination = keys(&v, "identity").public_key();
712        let created = Timestamp::from(1_790_899_200);
713        let event = ReputationAttestation::build(
714            &issuer,
715            &destination,
716            "a-source-account",
717            214,
718            4.866_574_074_074_074,
719            1_696_204_800,
720            created,
721            ATTESTATION_LIFETIME_SECS,
722        )
723        .expect("builds");
724        let parsed = ReputationAttestation::parse(&event, created, ATTESTATION_LIFETIME_SECS)
725            .expect("parses");
726        assert_eq!(parsed.issuer, issuer.public_key());
727        assert_eq!(parsed.destination, destination);
728        assert_eq!(parsed.subject, "a-source-account");
729        assert_eq!(parsed.reviews, 214);
730        assert_eq!(parsed.rating_text(), "4.87");
731        assert_eq!(parsed.since, 1_696_204_800);
732        assert_eq!(
733            parsed.expiration,
734            created.as_secs() + ATTESTATION_LIFETIME_SECS
735        );
736    }
737
738    #[test]
739    fn a_built_rebind_parses_back_to_what_was_built() {
740        let v = vectors();
741        let bound = keys(&v, "identity");
742        let issuer = keys(&v, "issuer-a").public_key();
743        let new = keys(&v, "new-identity").public_key();
744        let created = Timestamp::from(1_790_899_200);
745        let event =
746            ReputationRebind::build(&bound, &issuer, &new, created, REBIND_MAX_LIFETIME_SECS)
747                .expect("builds");
748        let parsed = ReputationRebind::parse(&event, created).expect("parses");
749        assert_eq!(parsed.bound_identity, bound.public_key());
750        assert_eq!((parsed.issuer, parsed.new_identity), (issuer, new));
751    }
752
753    #[test]
754    fn the_builders_refuse_what_a_destination_would_refuse() {
755        let issuer = Keys::generate();
756        let p = Keys::generate().public_key();
757        let at = Timestamp::from(1_790_899_200);
758        let since = 1_696_204_800;
759        let life = ATTESTATION_LIFETIME_SECS;
760        let build = |subject: &str, reviews, average, since, lifetime| {
761            ReputationAttestation::build(
762                &issuer, &p, subject, reviews, average, since, at, lifetime,
763            )
764        };
765        assert_eq!(
766            build("", 5, 4.0, since, life),
767            Err(AttestationError::InvalidInput("subject"))
768        );
769        assert_eq!(
770            build("a b", 5, 4.0, since, life),
771            Err(AttestationError::InvalidInput("subject"))
772        );
773        assert_eq!(
774            build("s", 4, 4.0, since, life),
775            Err(AttestationError::InvalidInput("reviews"))
776        );
777        assert_eq!(
778            build("s", 5, f64::NAN, since, life),
779            Err(AttestationError::InvalidInput("average"))
780        );
781        assert_eq!(
782            build("s", 5, 4.0, since + 1, life),
783            Err(AttestationError::InvalidInput("since"))
784        );
785        assert_eq!(
786            build("s", 5, 4.0, at.as_secs() + 86_400, life),
787            Err(AttestationError::InvalidInput("since"))
788        );
789        assert_eq!(
790            build("s", 5, 4.0, since, 0),
791            Err(AttestationError::InvalidInput("lifetime"))
792        );
793        assert_eq!(
794            build("s", 5, 4.0, since, ATTESTATION_LIFETIME_SECS + 1),
795            Err(AttestationError::InvalidInput("lifetime"))
796        );
797        assert_eq!(
798            ReputationRebind::build(&issuer, &p, &p, at, REBIND_MAX_LIFETIME_SECS + 1),
799            Err(AttestationError::InvalidInput("lifetime"))
800        );
801    }
802
803    #[test]
804    fn a_legacy_average_below_one_is_clamped_not_refused() {
805        let issuer = Keys::generate();
806        let at = Timestamp::from(1_790_899_200);
807        let event = ReputationAttestation::build(
808            &issuer,
809            &Keys::generate().public_key(),
810            "legacy",
811            5,
812            0.5,
813            1_696_204_800,
814            at,
815            ATTESTATION_LIFETIME_SECS,
816        )
817        .expect("builds");
818        let parsed =
819            ReputationAttestation::parse(&event, at, ATTESTATION_LIFETIME_SECS).expect("parses");
820        assert_eq!(parsed.rating_text(), "1.00");
821    }
822}