Skip to main content

offline_protocol/protocol/
custody.rs

1//! Custody v1: holding a neighbour's replication frame for hours.
2//!
3//! The store, the acceptance table, the quotas and the receipt codec from
4//! `docs/spec/custody.md`. The seams that feed it sit beside the forwarding
5//! path: `receive.rs` judges a frame at the drop point and transmits
6//! redeliveries, `send.rs` writes the deposit request on the depositor's own
7//! frame, and `mod.rs` restores, sweeps and answers receipts.
8//!
9//! Nothing here touches the forwarding suppression cache, settles an outbox
10//! entry, or sends anything. Those are the chapter's invariants, and keeping
11//! this module free of the engine's other state is what lets a test pin them
12//! against the store alone.
13
14use std::collections::{HashMap, HashSet, VecDeque};
15
16use offline_protocol_core::{Message, MessageId};
17use serde::{Deserialize, Serialize};
18
19use crate::config::{CustodyConfig, OverflowPolicy};
20use crate::protocol::prefixes::internal_prefixes;
21
22/// The reserved metadata key a depositor writes on its own frame
23/// (`docs/spec/wire-format.md`, reserved metadata keys). Engine-written,
24/// unsigned, and stripped by every device that forwards the frame.
25pub const CUSTODY_META_KEY: &str = "__custody";
26
27/// The one class token this version defines: Class A replication frames.
28pub const CUSTODY_CLASS_DATA: &str = "data";
29
30/// The receipt body version this build writes and reads.
31pub const CUSTODY_RECEIPT_VERSION: u8 = 1;
32
33/// Neighbours a held frame is re-originated toward during one hold, at most.
34///
35/// The chapter bounds the set of neighbours tried "the way tracked neighbours
36/// are bounded"; this is that bound, sized well above any neighbourhood the
37/// mesh targets so it is a ceiling against a pathological churn of addresses
38/// rather than a dial.
39pub const MAX_CUSTODY_NEIGHBOURS_PER_HOLD: usize = 64;
40
41/// Custodians one depositor remembers a receipt from, per outbox entry.
42///
43/// A receipt suppresses re-deposit toward the custodian that sent it. The set
44/// is keyed by outbox id, so it is bounded by the outbox; this bounds the other
45/// axis against a neighbourhood that mints addresses.
46pub const MAX_CUSTODY_RECEIPTS_PER_MESSAGE: usize = 64;
47
48/// Why a frame at the drop point was not taken into custody, in the order the
49/// chapter's acceptance table applies them.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum CustodyRefusal {
52    /// The frame carries no deposit request. Judged first, on every device,
53    /// so `refused_disabled` counts deposits an off device turned away rather
54    /// than every frame it ever abandoned.
55    NoRequest,
56    /// Custody is off on this device.
57    Disabled,
58    /// The request names a class this version does not define.
59    UnknownClass,
60    /// The outer prefix is not `__MLS_ENC__`.
61    NotSealed,
62    /// The carrier did not identify the link the frame arrived on.
63    UnprovenPeer,
64    /// The frame's sender is not the peer it arrived from: it came through a
65    /// forwarder that did not strip the request.
66    NotDepositor,
67    /// A frame with this identifier is already held: not stored, not
68    /// answered, counted under `duplicates` rather than as a refusal.
69    Duplicate,
70    /// The depositor is a stranger and the stranger tier is closed.
71    StrangerRefused,
72    /// The battery is below the soft relay floor. Judged before the budgets,
73    /// so a refusal for battery never evicts a held frame to make room for a
74    /// deposit that is then refused.
75    Battery,
76    /// The depositor's own budget is full and the policy does not make room.
77    DepositorFull,
78    /// The global budget is full and the policy does not make room.
79    StoreFull,
80}
81
82impl CustodyRefusal {
83    /// Every refusal reason, in table order, for the counters and their tests.
84    pub const ALL: &'static [Self] = &[
85        Self::NoRequest,
86        Self::Disabled,
87        Self::UnknownClass,
88        Self::NotSealed,
89        Self::UnprovenPeer,
90        Self::NotDepositor,
91        Self::Duplicate,
92        Self::StrangerRefused,
93        Self::Battery,
94        Self::DepositorFull,
95        Self::StoreFull,
96    ];
97
98    /// The reason as the chapter names it.
99    pub fn as_str(self) -> &'static str {
100        match self {
101            Self::Disabled => "disabled",
102            Self::NoRequest => "no_request",
103            Self::UnknownClass => "unknown_class",
104            Self::NotSealed => "not_sealed",
105            Self::UnprovenPeer => "unproven_peer",
106            Self::NotDepositor => "not_depositor",
107            Self::Duplicate => "duplicate",
108            Self::StrangerRefused => "stranger_refused",
109            Self::DepositorFull => "depositor_full",
110            Self::StoreFull => "store_full",
111            Self::Battery => "battery",
112        }
113    }
114}
115
116/// What a custodian has done, and is holding.
117///
118/// A snapshot, safe to poll. `held` and `held_bytes` are gauges; everything
119/// else is cumulative since start-up or the last erase. Aggregate and never per
120/// depositor, because a per-depositor answer on any wire would be the quota
121/// oracle the chapter refuses to be.
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
123pub struct CustodyStats {
124    /// Frames in custody right now.
125    pub held: u64,
126    /// Bytes in custody right now.
127    pub held_bytes: u64,
128    /// Deposits accepted.
129    pub accepted: u64,
130    /// Held frames handed to their recipient directly, and released.
131    pub delivered: u64,
132    /// Held frames re-originated toward a neighbour that is not the recipient.
133    pub re_originated: u64,
134    /// Held frames dropped at the end of their hold.
135    pub expired: u64,
136    /// Deposits of an identifier already held: not stored, not answered.
137    pub duplicates: u64,
138    /// Held frames evicted to admit a newer deposit under drop-oldest.
139    pub evicted: u64,
140    /// Receipts put on the arrival link.
141    pub receipts_sent: u64,
142    /// Receipts not sent: the depositor does not parse them, the link was
143    /// gone, or this device cannot sign.
144    pub receipts_dropped: u64,
145    /// Receipts this device received for an entry still in its outbox.
146    pub receipts_received: u64,
147    /// Receipts this device received naming nothing in its outbox, or that it
148    /// could not parse.
149    pub receipts_ignored: u64,
150    /// Refused: custody is off.
151    pub refused_disabled: u64,
152    /// Refused: no deposit request on the frame.
153    pub refused_no_request: u64,
154    /// Refused: unknown class token.
155    pub refused_unknown_class: u64,
156    /// Refused: not an `__MLS_ENC__` frame.
157    pub refused_not_sealed: u64,
158    /// Refused: the arrival link was not identified.
159    pub refused_unproven_peer: u64,
160    /// Refused: the sender is not the arrival peer.
161    pub refused_not_depositor: u64,
162    /// Refused: a stranger while the stranger tier is closed.
163    pub refused_stranger: u64,
164    /// Refused: the depositor's budget is full.
165    pub refused_depositor_full: u64,
166    /// Refused: the global budget is full.
167    pub refused_store_full: u64,
168    /// Refused: the battery is below the relay floor.
169    pub refused_battery: u64,
170}
171
172impl CustodyStats {
173    fn count(&mut self, refusal: CustodyRefusal) {
174        let slot = match refusal {
175            CustodyRefusal::Disabled => &mut self.refused_disabled,
176            CustodyRefusal::NoRequest => &mut self.refused_no_request,
177            CustodyRefusal::UnknownClass => &mut self.refused_unknown_class,
178            CustodyRefusal::NotSealed => &mut self.refused_not_sealed,
179            CustodyRefusal::UnprovenPeer => &mut self.refused_unproven_peer,
180            CustodyRefusal::NotDepositor => &mut self.refused_not_depositor,
181            CustodyRefusal::Duplicate => &mut self.duplicates,
182            CustodyRefusal::StrangerRefused => &mut self.refused_stranger,
183            CustodyRefusal::DepositorFull => &mut self.refused_depositor_full,
184            CustodyRefusal::StoreFull => &mut self.refused_store_full,
185            CustodyRefusal::Battery => &mut self.refused_battery,
186        };
187        *slot = slot.saturating_add(1);
188    }
189
190    /// The counter a refusal reason lands in, for tests that walk the table.
191    pub fn refusals(&self, refusal: CustodyRefusal) -> u64 {
192        match refusal {
193            CustodyRefusal::Disabled => self.refused_disabled,
194            CustodyRefusal::NoRequest => self.refused_no_request,
195            CustodyRefusal::UnknownClass => self.refused_unknown_class,
196            CustodyRefusal::NotSealed => self.refused_not_sealed,
197            CustodyRefusal::UnprovenPeer => self.refused_unproven_peer,
198            CustodyRefusal::NotDepositor => self.refused_not_depositor,
199            CustodyRefusal::Duplicate => self.duplicates,
200            CustodyRefusal::StrangerRefused => self.refused_stranger,
201            CustodyRefusal::DepositorFull => self.refused_depositor_full,
202            CustodyRefusal::StoreFull => self.refused_store_full,
203            CustodyRefusal::Battery => self.refused_battery,
204        }
205    }
206}
207
208/// One frame in custody.
209#[derive(Debug, Clone)]
210pub(crate) struct HeldFrame {
211    /// The frame as it arrived, hop fields as the forwarding path adjusted
212    /// them, with the deposit request removed.
213    pub(crate) message: Message,
214    /// The frame's sender, which is also the peer it arrived from.
215    pub(crate) depositor: String,
216    /// Wall-clock acceptance time, milliseconds since the Unix epoch. Never a
217    /// monotonic instant: that clock does not run while the device is off.
218    pub(crate) accepted_at_ms: i64,
219    /// The class token the depositor asserted.
220    pub(crate) class: String,
221    /// The frame's footprint against the byte budgets.
222    bytes: usize,
223    /// Neighbours this frame has been re-originated toward during its hold.
224    tried: HashSet<String>,
225    /// The neighbour a marked forward is queued toward right now, if any.
226    in_flight: Option<String>,
227}
228
229/// A frame at the drop point, with everything the acceptance table reads.
230pub(crate) struct CustodyCandidate<'a> {
231    /// The forwarded copy: hop fields adjusted, deposit request removed.
232    pub(crate) message: Message,
233    /// The class token the frame carried on arrival, if any.
234    pub(crate) request: Option<&'a str>,
235    /// The peer the frame arrived from, when the carrier proved the link.
236    pub(crate) arrival_peer: Option<&'a str>,
237    /// Whether this device holds an established session with that peer.
238    pub(crate) session_tier: bool,
239    /// Whether the battery is above the soft relay floor.
240    pub(crate) battery_ok: bool,
241    /// Wall-clock now, milliseconds since the Unix epoch.
242    pub(crate) now_ms: i64,
243}
244
245/// A deposit the store took on.
246pub(crate) struct Accepted {
247    /// The held frame's identifier.
248    pub(crate) id: MessageId,
249    /// The depositor, which is the arrival peer and the frame's sender.
250    pub(crate) depositor: String,
251    /// The class token, for the durable record.
252    pub(crate) class: String,
253    /// Frames evicted under drop-oldest to admit this one; their records are
254    /// the caller's to delete.
255    pub(crate) evicted: Vec<MessageId>,
256}
257
258/// The footprint a held frame is charged against the byte budgets.
259///
260/// The same accounting the pending-decrypt queue uses: the variable fields
261/// (content, binary content, metadata, media metadata) plus the addresses. The
262/// fixed cost of a record is bounded by the entry caps, so it is not charged
263/// here; the byte budget exists to bound payload bloat.
264fn frame_footprint(message: &Message) -> usize {
265    let metadata_bytes: usize = message
266        .metadata
267        .iter()
268        .map(|(key, value)| key.len() + value.len())
269        .sum();
270    let media_bytes = message
271        .media_metadata
272        .as_ref()
273        .map(|media| {
274            media.mime_type.len()
275                + media.file_name.len()
276                + media
277                    .thumbnail_base64
278                    .as_ref()
279                    .map(String::len)
280                    .unwrap_or(0)
281        })
282        .unwrap_or(0);
283    message.content.len()
284        + message
285            .binary_content
286            .as_ref()
287            .map(|binary| binary.len())
288            .unwrap_or(0)
289        + message.sender.as_str().len()
290        + message.recipient.as_str().len()
291        + message.app_id.as_str().len()
292        + metadata_bytes
293        + media_bytes
294}
295
296/// Which quota a depositor is judged under.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298enum Tier {
299    Session,
300    Stranger,
301}
302
303/// What the acceptance table established about a candidate it did not
304/// refuse, so the budgets can be applied without re-proving any of it.
305struct Admitted<'a> {
306    tier: Tier,
307    /// The arrival peer, which the table proved is also the frame's sender.
308    depositor: &'a str,
309    /// The class token, which the table proved is one this version defines.
310    token: &'a str,
311}
312
313#[derive(Debug, Clone, Copy, Default)]
314struct Usage {
315    entries: usize,
316    bytes: usize,
317}
318
319/// The custody store: held frames, per-depositor and global budgets, and the
320/// counters.
321///
322/// Memory-only; the sealed records are written and deleted by the engine's
323/// storage seam beside the pending-decrypt records, and restored through
324/// [`Self::admit_restored`].
325pub(crate) struct CustodyStore {
326    config: CustodyConfig,
327    entries: HashMap<MessageId, HeldFrame>,
328    /// Acceptance order, oldest first. Ids are removed lazily: an id here that
329    /// is no longer in `entries` is skipped.
330    order: VecDeque<MessageId>,
331    per_depositor: HashMap<String, Usage>,
332    total: Usage,
333    stats: CustodyStats,
334}
335
336impl CustodyStore {
337    pub(crate) fn new(config: CustodyConfig) -> Self {
338        Self {
339            config,
340            entries: HashMap::new(),
341            order: VecDeque::new(),
342            per_depositor: HashMap::new(),
343            total: Usage::default(),
344            stats: CustodyStats::default(),
345        }
346    }
347
348    pub(crate) fn config(&self) -> &CustodyConfig {
349        &self.config
350    }
351
352    pub(crate) fn is_enabled(&self) -> bool {
353        self.config.enabled
354    }
355
356    pub(crate) fn is_empty(&self) -> bool {
357        self.entries.is_empty()
358    }
359
360    #[cfg(test)]
361    pub(crate) fn len(&self) -> usize {
362        self.entries.len()
363    }
364
365    #[cfg(test)]
366    pub(crate) fn contains(&self, id: &MessageId) -> bool {
367        self.entries.contains_key(id)
368    }
369
370    pub(crate) fn get(&self, id: &MessageId) -> Option<&HeldFrame> {
371        self.entries.get(id)
372    }
373
374    /// The counters, with the gauges filled from the store.
375    pub(crate) fn stats(&self) -> CustodyStats {
376        let mut stats = self.stats.clone();
377        stats.held = self.entries.len() as u64;
378        stats.held_bytes = self.total.bytes as u64;
379        stats
380    }
381
382    /// Judges a frame at the drop point and takes it into custody when every
383    /// row of the acceptance table holds.
384    ///
385    /// Counts the refusal or the acceptance. Touches nothing outside the
386    /// store: the caller persists the record, deletes the evicted ones and
387    /// answers with the receipt.
388    pub(crate) fn judge(
389        &mut self,
390        candidate: CustodyCandidate<'_>,
391    ) -> Result<Accepted, CustodyRefusal> {
392        let Admitted {
393            tier,
394            depositor,
395            token,
396        } = match self.check(&candidate) {
397            Ok(admitted) => admitted,
398            Err(refusal) => {
399                // Every row is counted. `no_request` is judged before
400                // `disabled`, so on an off device the latter counts deposits
401                // turned away rather than every abandoned forward, and an
402                // operator can tell "off" from "nobody asked"; a duplicate
403                // lands in its own counter.
404                self.stats.count(refusal);
405                return Err(refusal);
406            }
407        };
408        let depositor = depositor.to_string();
409        let class = token.to_string();
410        let bytes = frame_footprint(&candidate.message);
411        let (max_entries, max_bytes) = match tier {
412            Tier::Session => (
413                self.config.max_entries_per_depositor,
414                self.config.max_bytes_per_depositor,
415            ),
416            Tier::Stranger => (
417                self.config.stranger_max_entries,
418                self.config.stranger_max_bytes,
419            ),
420        };
421
422        let mut evicted = Vec::new();
423
424        // The depositor's own budget first: under drop-oldest it is the
425        // depositor's oldest frame that goes, which is what keeps one
426        // depositor's flood from evicting everyone else's frames.
427        if bytes > max_bytes {
428            self.stats.count(CustodyRefusal::DepositorFull);
429            return Err(CustodyRefusal::DepositorFull);
430        }
431        while !self.fits_depositor(&depositor, bytes, max_entries, max_bytes) {
432            match self.config.overflow_policy {
433                OverflowPolicy::DropOldest => match self.oldest_of(&depositor) {
434                    Some(victim) => {
435                        self.remove_inner(&victim);
436                        self.stats.evicted = self.stats.evicted.saturating_add(1);
437                        evicted.push(victim);
438                    }
439                    None => {
440                        self.stats.count(CustodyRefusal::DepositorFull);
441                        return Err(CustodyRefusal::DepositorFull);
442                    }
443                },
444                OverflowPolicy::DropNewest => {
445                    self.stats.count(CustodyRefusal::DepositorFull);
446                    return Err(CustodyRefusal::DepositorFull);
447                }
448            }
449        }
450
451        if bytes > self.config.max_bytes {
452            self.stats.count(CustodyRefusal::StoreFull);
453            return Err(CustodyRefusal::StoreFull);
454        }
455        while !self.fits_store(bytes) {
456            match self.config.overflow_policy {
457                OverflowPolicy::DropOldest => match self.oldest() {
458                    Some(victim) => {
459                        self.remove_inner(&victim);
460                        self.stats.evicted = self.stats.evicted.saturating_add(1);
461                        evicted.push(victim);
462                    }
463                    None => {
464                        self.stats.count(CustodyRefusal::StoreFull);
465                        return Err(CustodyRefusal::StoreFull);
466                    }
467                },
468                OverflowPolicy::DropNewest => {
469                    self.stats.count(CustodyRefusal::StoreFull);
470                    return Err(CustodyRefusal::StoreFull);
471                }
472            }
473        }
474
475        let id = candidate.message.id.clone();
476        self.insert(HeldFrame {
477            message: candidate.message,
478            depositor: depositor.clone(),
479            accepted_at_ms: candidate.now_ms,
480            class: class.clone(),
481            bytes,
482            tried: HashSet::new(),
483            in_flight: None,
484        });
485        self.stats.accepted = self.stats.accepted.saturating_add(1);
486
487        Ok(Accepted {
488            id,
489            depositor,
490            class,
491            evicted,
492        })
493    }
494
495    /// The acceptance table, in the chapter's order: the request before the
496    /// switch, the depositor's identity before the tiers, and the battery
497    /// before the budgets, which [`Self::judge`] applies after this returns.
498    fn check<'a>(&self, c: &CustodyCandidate<'a>) -> Result<Admitted<'a>, CustodyRefusal> {
499        let Some(token) = c.request else {
500            return Err(CustodyRefusal::NoRequest);
501        };
502        if !self.config.enabled {
503            return Err(CustodyRefusal::Disabled);
504        }
505        if token != CUSTODY_CLASS_DATA {
506            return Err(CustodyRefusal::UnknownClass);
507        }
508        if !c.message.content.starts_with(internal_prefixes::ENCRYPTED) {
509            return Err(CustodyRefusal::NotSealed);
510        }
511        let Some(peer) = c.arrival_peer else {
512            return Err(CustodyRefusal::UnprovenPeer);
513        };
514        if c.message.sender.as_str() != peer {
515            return Err(CustodyRefusal::NotDepositor);
516        }
517        if self.entries.contains_key(&c.message.id) {
518            // Not a refusal in the table's sense: the first receipt already
519            // suppressed re-deposit at a depositor that received it, and one
520            // that did not deposits again on its next retry. Absorbed here.
521            return Err(CustodyRefusal::Duplicate);
522        }
523        let tier = if c.session_tier {
524            Tier::Session
525        } else {
526            Tier::Stranger
527        };
528        if tier == Tier::Stranger
529            && (self.config.stranger_max_entries == 0 || self.config.stranger_max_bytes == 0)
530        {
531            return Err(CustodyRefusal::StrangerRefused);
532        }
533        if !c.battery_ok {
534            return Err(CustodyRefusal::Battery);
535        }
536        Ok(Admitted {
537            tier,
538            depositor: peer,
539            token,
540        })
541    }
542
543    fn fits_depositor(
544        &self,
545        depositor: &str,
546        bytes: usize,
547        max_entries: usize,
548        max_bytes: usize,
549    ) -> bool {
550        let usage = self
551            .per_depositor
552            .get(depositor)
553            .copied()
554            .unwrap_or_default();
555        usage.entries < max_entries && usage.bytes.saturating_add(bytes) <= max_bytes
556    }
557
558    fn fits_store(&self, bytes: usize) -> bool {
559        self.total.entries < self.config.max_entries
560            && self.total.bytes.saturating_add(bytes) <= self.config.max_bytes
561    }
562
563    fn oldest(&self) -> Option<MessageId> {
564        self.order
565            .iter()
566            .find(|id| self.entries.contains_key(*id))
567            .cloned()
568    }
569
570    fn oldest_of(&self, depositor: &str) -> Option<MessageId> {
571        self.order
572            .iter()
573            .find(|id| {
574                self.entries
575                    .get(*id)
576                    .is_some_and(|held| held.depositor == depositor)
577            })
578            .cloned()
579    }
580
581    fn insert(&mut self, held: HeldFrame) {
582        let usage = self
583            .per_depositor
584            .entry(held.depositor.clone())
585            .or_default();
586        usage.entries += 1;
587        usage.bytes = usage.bytes.saturating_add(held.bytes);
588        self.total.entries += 1;
589        self.total.bytes = self.total.bytes.saturating_add(held.bytes);
590        self.order.push_back(held.message.id.clone());
591        self.entries.insert(held.message.id.clone(), held);
592    }
593
594    fn remove_inner(&mut self, id: &MessageId) -> Option<HeldFrame> {
595        let held = self.entries.remove(id)?;
596        if let Some(usage) = self.per_depositor.get_mut(&held.depositor) {
597            usage.entries = usage.entries.saturating_sub(1);
598            usage.bytes = usage.bytes.saturating_sub(held.bytes);
599            if usage.entries == 0 {
600                self.per_depositor.remove(&held.depositor);
601            }
602        }
603        self.total.entries = self.total.entries.saturating_sub(1);
604        self.total.bytes = self.total.bytes.saturating_sub(held.bytes);
605        self.order.retain(|queued| queued != id);
606        Some(held)
607    }
608
609    /// Puts a record read back from storage into the store, or refuses it.
610    ///
611    /// Refused when the identifier is already held or the budgets in force
612    /// have no room: restore keeps records "exactly as stored" but under the
613    /// configuration of the launch, so a lowered cap drops the excess rather
614    /// than exceeding the cap the operator set. Never evicts, never counts an
615    /// acceptance: nothing was deposited at restore.
616    pub(crate) fn admit_restored(
617        &mut self,
618        message: Message,
619        depositor: String,
620        accepted_at_ms: i64,
621        class: String,
622    ) -> bool {
623        if self.entries.contains_key(&message.id) {
624            return false;
625        }
626        let bytes = frame_footprint(&message);
627        let (max_entries, max_bytes) = (
628            self.config
629                .max_entries_per_depositor
630                .max(self.config.stranger_max_entries),
631            self.config
632                .max_bytes_per_depositor
633                .max(self.config.stranger_max_bytes),
634        );
635        if !self.fits_depositor(&depositor, bytes, max_entries, max_bytes)
636            || !self.fits_store(bytes)
637        {
638            return false;
639        }
640        self.insert(HeldFrame {
641            message,
642            depositor,
643            accepted_at_ms,
644            class,
645            bytes,
646            tried: HashSet::new(),
647            in_flight: None,
648        });
649        true
650    }
651
652    /// Drops every record whose acceptance time is older than `hold_ms` at
653    /// `now_ms`, returning their identifiers so the caller deletes the
654    /// records. Judged against the hold in force now, not the one at
655    /// acceptance, so lowering the dial expires records already held.
656    ///
657    /// A record dated in the future is kept: a clock that moved back is not a
658    /// reason to drop other people's traffic, and it ages out once the clock
659    /// passes its acceptance time again.
660    pub(crate) fn expire(&mut self, now_ms: i64, hold_ms: u64) -> Vec<MessageId> {
661        let hold = i64::try_from(hold_ms).unwrap_or(i64::MAX);
662        let expired: Vec<MessageId> = self
663            .entries
664            .values()
665            .filter(|held| now_ms.saturating_sub(held.accepted_at_ms) > hold)
666            .map(|held| held.message.id.clone())
667            .collect();
668        for id in &expired {
669            self.remove_inner(id);
670            self.stats.expired = self.stats.expired.saturating_add(1);
671        }
672        expired
673    }
674
675    /// Held frames eligible for re-origination toward `peer`, oldest first.
676    ///
677    /// Never the depositor's own frames back to it; never a frame already
678    /// queued toward someone; at most once per distinct neighbour during the
679    /// hold, except toward the frame's own recipient, which is the delivery
680    /// attempt itself and is retried whenever that neighbour appears.
681    pub(crate) fn candidates_for(&self, peer: &str) -> Vec<MessageId> {
682        self.order
683            .iter()
684            .filter_map(|id| self.entries.get(id))
685            .filter(|held| held.depositor != peer && held.in_flight.is_none())
686            .filter(|held| {
687                held.message.recipient.as_str() == peer
688                    || (!held.tried.contains(peer)
689                        && held.tried.len() < MAX_CUSTODY_NEIGHBOURS_PER_HOLD)
690            })
691            .map(|held| held.message.id.clone())
692            .collect()
693    }
694
695    /// Records that a marked forward of `id` is queued toward `peer`.
696    pub(crate) fn mark_queued(&mut self, id: &MessageId, peer: &str) {
697        if let Some(held) = self.entries.get_mut(id) {
698            held.tried.insert(peer.to_string());
699            held.in_flight = Some(peer.to_string());
700        }
701    }
702
703    /// A marked forward reached no link and came back from the drop point.
704    /// Not a deposit, not a refusal, not counted, and the neighbour it was
705    /// queued toward is untried again: an attempt that never transmitted has
706    /// not spent the one re-origination the hold allows toward that neighbour.
707    pub(crate) fn returned(&mut self, id: &MessageId) {
708        if let Some(held) = self.entries.get_mut(id) {
709            if let Some(peer) = held.in_flight.take() {
710                held.tried.remove(&peer);
711            }
712        }
713    }
714
715    /// A marked forward was transmitted to a neighbour that is not the
716    /// recipient. The frame stays held until its hold ends.
717    pub(crate) fn record_re_originated(&mut self, id: &MessageId) {
718        if let Some(held) = self.entries.get_mut(id) {
719            held.in_flight = None;
720        }
721        self.stats.re_originated = self.stats.re_originated.saturating_add(1);
722    }
723
724    /// A marked forward was handed to its recipient: the frame leaves custody.
725    /// Returns the record so the caller deletes it.
726    pub(crate) fn record_delivered(&mut self, id: &MessageId) -> Option<HeldFrame> {
727        let held = self.remove_inner(id)?;
728        self.stats.delivered = self.stats.delivered.saturating_add(1);
729        Some(held)
730    }
731
732    pub(crate) fn record_receipt_sent(&mut self) {
733        self.stats.receipts_sent = self.stats.receipts_sent.saturating_add(1);
734    }
735
736    pub(crate) fn record_receipt_dropped(&mut self) {
737        self.stats.receipts_dropped = self.stats.receipts_dropped.saturating_add(1);
738    }
739
740    pub(crate) fn record_receipt_received(&mut self) {
741        self.stats.receipts_received = self.stats.receipts_received.saturating_add(1);
742    }
743
744    pub(crate) fn record_receipt_ignored(&mut self) {
745        self.stats.receipts_ignored = self.stats.receipts_ignored.saturating_add(1);
746    }
747
748    /// Drops every held frame and resets the counters, returning the
749    /// identifiers so the caller deletes the records.
750    pub(crate) fn erase(&mut self) -> Vec<MessageId> {
751        let ids: Vec<MessageId> = self.entries.keys().cloned().collect();
752        self.entries.clear();
753        self.order.clear();
754        self.per_depositor.clear();
755        self.total = Usage::default();
756        self.stats = CustodyStats::default();
757        ids
758    }
759}
760
761/// The receipt body: `{"v":1,"id":"<held frame id>","hold_ms":<u64>}`.
762///
763/// `hold_ms` is relative to the receipt's own timestamp, so the depositor
764/// applies it to its own clock and skew cannot expire a valid receipt.
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub(crate) struct CustodyReceipt {
767    pub(crate) v: u8,
768    pub(crate) id: String,
769    pub(crate) hold_ms: u64,
770}
771
772/// Why a receipt body was refused.
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub(crate) enum ReceiptDecodeError {
775    /// Not a JSON object, or a field of the wrong shape.
776    Malformed,
777    /// A body version this build does not know.
778    UnknownVersion,
779    /// An empty identifier names nothing.
780    EmptyId,
781}
782
783/// Encodes a receipt for `id`, prefix included.
784pub(crate) fn encode_receipt(id: &str, hold_ms: u64) -> String {
785    let body = CustodyReceipt {
786        v: CUSTODY_RECEIPT_VERSION,
787        id: id.to_string(),
788        hold_ms,
789    };
790    // Three scalar fields cannot fail to serialize.
791    let json = serde_json::to_string(&body).unwrap_or_default();
792    format!("{}{}", internal_prefixes::CUSTODY_RECEIPT, json)
793}
794
795/// Decodes a receipt body (the text after the prefix).
796///
797/// The version is read before the body, so a future shape is refused on its
798/// version rather than failing to parse; unknown fields are ignored.
799pub(crate) fn decode_receipt(body: &str) -> Result<CustodyReceipt, ReceiptDecodeError> {
800    let value: serde_json::Value =
801        serde_json::from_str(body).map_err(|_| ReceiptDecodeError::Malformed)?;
802    if !value.is_object() {
803        return Err(ReceiptDecodeError::Malformed);
804    }
805    match value.get("v").and_then(serde_json::Value::as_u64) {
806        Some(v) if v == u64::from(CUSTODY_RECEIPT_VERSION) => {}
807        Some(_) => return Err(ReceiptDecodeError::UnknownVersion),
808        None => return Err(ReceiptDecodeError::Malformed),
809    }
810    let receipt: CustodyReceipt =
811        serde_json::from_value(value).map_err(|_| ReceiptDecodeError::Malformed)?;
812    if receipt.id.is_empty() {
813        return Err(ReceiptDecodeError::EmptyId);
814    }
815    Ok(receipt)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use offline_protocol_core::{AppId, UserId};
822
823    fn config() -> CustodyConfig {
824        CustodyConfig {
825            enabled: true,
826            ..CustodyConfig::default()
827        }
828    }
829
830    /// A message id derived from a label: ids are UUIDs on the wire, and a
831    /// test reads better naming them.
832    fn mid(label: &str) -> MessageId {
833        let mut acc: u64 = 0xcbf2_9ce4_8422_2325;
834        for byte in label.bytes() {
835            acc ^= u64::from(byte);
836            acc = acc.wrapping_mul(0x0100_0000_01b3);
837        }
838        MessageId::from_str(&format!(
839            "00000000-0000-4000-8000-{:012x}",
840            acc & 0xffff_ffff_ffff
841        ))
842        .unwrap()
843    }
844
845    fn sealed(from: &str, to: &str, id: &str) -> Message {
846        let mut message = Message::new(
847            UserId::new(from).unwrap(),
848            UserId::new(to).unwrap(),
849            AppId::new("app").unwrap(),
850            format!("{}opaque-{id}", internal_prefixes::ENCRYPTED),
851        );
852        message.id = mid(id);
853        message
854    }
855
856    fn candidate<'a>(message: Message, from: &'a str) -> CustodyCandidate<'a> {
857        CustodyCandidate {
858            message,
859            request: Some(CUSTODY_CLASS_DATA),
860            arrival_peer: Some(from),
861            session_tier: true,
862            battery_ok: true,
863            now_ms: 1_000,
864        }
865    }
866
867    #[test]
868    fn a_deposit_from_the_depositor_over_its_own_link_is_accepted() {
869        let mut store = CustodyStore::new(config());
870        let accepted = store
871            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
872            .expect("accepted");
873        assert_eq!(accepted.depositor, "alice");
874        assert_eq!(accepted.class, CUSTODY_CLASS_DATA);
875        assert!(store.contains(&mid("m1")));
876        assert_eq!(store.stats().accepted, 1);
877        assert_eq!(store.stats().held, 1);
878    }
879
880    #[test]
881    fn every_refusal_reason_is_reachable_and_counted() {
882        let refuse = |store: &mut CustodyStore, c: CustodyCandidate<'_>, expected| {
883            let before = store.stats().refusals(expected);
884            assert_eq!(store.judge(c).err(), Some(expected));
885            assert_eq!(store.stats().refusals(expected), before + 1);
886        };
887
888        let mut off = CustodyStore::new(CustodyConfig::default());
889        refuse(
890            &mut off,
891            candidate(sealed("alice", "carol", "m1"), "alice"),
892            CustodyRefusal::Disabled,
893        );
894
895        let mut store = CustodyStore::new(config());
896        let mut no_request = candidate(sealed("alice", "carol", "m1"), "alice");
897        no_request.request = None;
898        refuse(&mut store, no_request, CustodyRefusal::NoRequest);
899
900        let mut unknown = candidate(sealed("alice", "carol", "m1"), "alice");
901        unknown.request = Some("media");
902        refuse(&mut store, unknown, CustodyRefusal::UnknownClass);
903
904        let mut plain = sealed("alice", "carol", "m1");
905        plain.content = "hello".to_string();
906        refuse(
907            &mut store,
908            candidate(plain, "alice"),
909            CustodyRefusal::NotSealed,
910        );
911
912        let mut unproven = candidate(sealed("alice", "carol", "m1"), "alice");
913        unproven.arrival_peer = None;
914        refuse(&mut store, unproven, CustodyRefusal::UnprovenPeer);
915
916        refuse(
917            &mut store,
918            candidate(sealed("alice", "carol", "m1"), "bob"),
919            CustodyRefusal::NotDepositor,
920        );
921
922        let mut stranger = candidate(sealed("alice", "carol", "m1"), "alice");
923        stranger.session_tier = false;
924        refuse(&mut store, stranger, CustodyRefusal::StrangerRefused);
925
926        let mut flat = candidate(sealed("alice", "carol", "m1"), "alice");
927        flat.battery_ok = false;
928        refuse(&mut store, flat, CustodyRefusal::Battery);
929
930        // Depositor full, under drop-newest so nothing is evicted.
931        let mut tight = CustodyStore::new(CustodyConfig {
932            max_entries_per_depositor: 1,
933            overflow_policy: OverflowPolicy::DropNewest,
934            ..config()
935        });
936        tight
937            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
938            .unwrap();
939        refuse(
940            &mut tight,
941            candidate(sealed("alice", "carol", "m2"), "alice"),
942            CustodyRefusal::DepositorFull,
943        );
944
945        // Store full: two depositors, a global cap of one.
946        let mut global = CustodyStore::new(CustodyConfig {
947            max_entries: 1,
948            overflow_policy: OverflowPolicy::DropNewest,
949            ..config()
950        });
951        global
952            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
953            .unwrap();
954        refuse(
955            &mut global,
956            candidate(sealed("bob", "carol", "m2"), "bob"),
957            CustodyRefusal::StoreFull,
958        );
959    }
960
961    #[test]
962    fn a_duplicate_is_neither_stored_nor_counted_as_a_refusal() {
963        let mut store = CustodyStore::new(config());
964        store
965            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
966            .unwrap();
967        let verdict = store.judge(candidate(sealed("alice", "carol", "m1"), "alice"));
968        assert_eq!(verdict.err(), Some(CustodyRefusal::Duplicate));
969        assert_eq!(store.len(), 1);
970        assert_eq!(store.stats().accepted, 1);
971        assert_eq!(store.stats().duplicates, 1);
972        for reason in CustodyRefusal::ALL
973            .iter()
974            .filter(|reason| **reason != CustodyRefusal::Duplicate)
975        {
976            assert_eq!(store.stats().refusals(*reason), 0, "{reason:?}");
977        }
978    }
979
980    #[test]
981    fn drop_oldest_evicts_the_depositors_own_oldest_frame_first() {
982        let mut store = CustodyStore::new(CustodyConfig {
983            max_entries_per_depositor: 1,
984            ..config()
985        });
986        store
987            .judge(candidate(sealed("alice", "carol", "a1"), "alice"))
988            .unwrap();
989        store
990            .judge(candidate(sealed("bob", "carol", "b1"), "bob"))
991            .unwrap();
992        let accepted = store
993            .judge(candidate(sealed("alice", "carol", "a2"), "alice"))
994            .unwrap();
995        assert_eq!(accepted.evicted, vec![mid("a1")]);
996        assert!(store.contains(&mid("b1")));
997        assert!(store.contains(&mid("a2")));
998        assert_eq!(store.stats().evicted, 1);
999    }
1000
1001    #[test]
1002    fn a_stranger_is_admitted_only_under_the_stranger_tier() {
1003        let mut store = CustodyStore::new(CustodyConfig {
1004            stranger_max_entries: 1,
1005            stranger_max_bytes: 128 * 1024,
1006            ..config()
1007        });
1008        let mut stranger = candidate(sealed("mallory", "carol", "s1"), "mallory");
1009        stranger.session_tier = false;
1010        store.judge(stranger).expect("one stranger frame");
1011        let mut second = candidate(sealed("mallory", "carol", "s2"), "mallory");
1012        second.session_tier = false;
1013        // The tier is one entry deep, and drop-oldest keeps it that deep.
1014        let accepted = store.judge(second).expect("evicts the first");
1015        assert_eq!(accepted.evicted.len(), 1);
1016    }
1017
1018    #[test]
1019    fn expiry_is_judged_in_wall_time_against_the_hold_in_force() {
1020        let mut store = CustodyStore::new(config());
1021        store
1022            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1023            .unwrap();
1024        assert!(store.expire(1_000 + 5_000, 6_000).is_empty());
1025        // A lowered hold expires a record accepted under a longer one.
1026        assert_eq!(store.expire(1_000 + 5_000, 4_000).len(), 1);
1027        assert_eq!(store.stats().expired, 1);
1028        assert!(store.is_empty());
1029    }
1030
1031    #[test]
1032    fn a_record_dated_in_the_future_is_kept() {
1033        let mut store = CustodyStore::new(config());
1034        let mut future = candidate(sealed("alice", "carol", "m1"), "alice");
1035        future.now_ms = 10_000;
1036        store.judge(future).unwrap();
1037        assert!(store.expire(1_000, 1).is_empty());
1038    }
1039
1040    #[test]
1041    fn redelivery_candidates_skip_the_depositor_and_neighbours_already_tried() {
1042        let mut store = CustodyStore::new(config());
1043        store
1044            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1045            .unwrap();
1046        let id = mid("m1");
1047        assert!(
1048            store.candidates_for("alice").is_empty(),
1049            "never back to the depositor"
1050        );
1051        assert_eq!(store.candidates_for("bob"), vec![id.clone()]);
1052        store.mark_queued(&id, "bob");
1053        assert!(store.candidates_for("bob").is_empty(), "in flight");
1054        store.record_re_originated(&id);
1055        assert!(store.candidates_for("bob").is_empty(), "once per neighbour");
1056        assert_eq!(store.candidates_for("dave"), vec![id.clone()]);
1057        // The recipient is always worth trying again.
1058        store.mark_queued(&id, "carol");
1059        store.returned(&id);
1060        assert_eq!(store.candidates_for("carol"), vec![id]);
1061    }
1062
1063    #[test]
1064    fn delivery_releases_the_frame_and_re_origination_keeps_it() {
1065        let mut store = CustodyStore::new(config());
1066        store
1067            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1068            .unwrap();
1069        let id = mid("m1");
1070        store.mark_queued(&id, "bob");
1071        store.record_re_originated(&id);
1072        assert!(store.contains(&id));
1073        assert_eq!(store.stats().re_originated, 1);
1074        store.mark_queued(&id, "carol");
1075        assert!(store.record_delivered(&id).is_some());
1076        assert!(!store.contains(&id));
1077        assert_eq!(store.stats().delivered, 1);
1078        assert_eq!(store.stats().held, 0);
1079    }
1080
1081    #[test]
1082    fn erase_drops_everything_and_resets_the_counters() {
1083        let mut store = CustodyStore::new(config());
1084        store
1085            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1086            .unwrap();
1087        let ids = store.erase();
1088        assert_eq!(ids, vec![mid("m1")]);
1089        assert!(store.is_empty());
1090        assert_eq!(store.stats(), CustodyStats::default());
1091    }
1092
1093    #[test]
1094    fn restore_refuses_a_duplicate_and_never_counts_an_acceptance() {
1095        let mut store = CustodyStore::new(config());
1096        assert!(store.admit_restored(
1097            sealed("alice", "carol", "m1"),
1098            "alice".into(),
1099            5,
1100            CUSTODY_CLASS_DATA.into()
1101        ));
1102        assert!(!store.admit_restored(
1103            sealed("alice", "carol", "m1"),
1104            "alice".into(),
1105            5,
1106            CUSTODY_CLASS_DATA.into()
1107        ));
1108        assert_eq!(store.stats().accepted, 0);
1109        assert_eq!(store.stats().held, 1);
1110    }
1111
1112    #[test]
1113    fn a_frame_asking_for_nothing_is_no_request_before_disabled() {
1114        let mut off = CustodyStore::new(CustodyConfig::default());
1115        let mut silent = candidate(sealed("alice", "carol", "m1"), "alice");
1116        silent.request = None;
1117        assert_eq!(off.judge(silent).err(), Some(CustodyRefusal::NoRequest));
1118        assert_eq!(off.stats().refused_no_request, 1);
1119        assert_eq!(off.stats().refused_disabled, 0, "nobody asked");
1120
1121        let verdict = off.judge(candidate(sealed("alice", "carol", "m1"), "alice"));
1122        assert_eq!(verdict.err(), Some(CustodyRefusal::Disabled));
1123        assert_eq!(off.stats().refused_disabled, 1, "a deposit turned away");
1124    }
1125
1126    #[test]
1127    fn battery_is_judged_before_the_quotas_so_a_refusal_evicts_nothing() {
1128        let mut store = CustodyStore::new(CustodyConfig {
1129            max_entries_per_depositor: 1,
1130            ..config()
1131        });
1132        store
1133            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1134            .unwrap();
1135        let mut flat = candidate(sealed("alice", "carol", "m2"), "alice");
1136        flat.battery_ok = false;
1137        assert_eq!(store.judge(flat).err(), Some(CustodyRefusal::Battery));
1138        assert_eq!(
1139            store.stats().evicted,
1140            0,
1141            "nothing made room for a refused deposit"
1142        );
1143        assert!(store.contains(&mid("m1")));
1144        assert_eq!(store.stats().refused_battery, 1);
1145    }
1146
1147    #[test]
1148    fn a_returned_forward_leaves_its_neighbour_untried() {
1149        let mut store = CustodyStore::new(config());
1150        store
1151            .judge(candidate(sealed("alice", "carol", "m1"), "alice"))
1152            .unwrap();
1153        let id = mid("m1");
1154        store.mark_queued(&id, "dave");
1155        assert!(store.candidates_for("dave").is_empty(), "in flight");
1156        store.returned(&id);
1157        assert_eq!(
1158            store.candidates_for("dave"),
1159            vec![id.clone()],
1160            "an attempt that never transmitted is not spent"
1161        );
1162        store.mark_queued(&id, "dave");
1163        store.record_re_originated(&id);
1164        assert!(store.candidates_for("dave").is_empty(), "a transmission is");
1165    }
1166
1167    #[test]
1168    fn the_receipt_round_trips_and_refuses_what_the_chapter_refuses() {
1169        let wire = encode_receipt("m1", 21_600_000);
1170        assert_eq!(
1171            wire,
1172            format!(
1173                "{}{{\"v\":1,\"id\":\"m1\",\"hold_ms\":21600000}}",
1174                internal_prefixes::CUSTODY_RECEIPT
1175            )
1176        );
1177        let body = wire
1178            .strip_prefix(internal_prefixes::CUSTODY_RECEIPT)
1179            .unwrap();
1180        let receipt = decode_receipt(body).unwrap();
1181        assert_eq!(receipt.id, "m1");
1182        assert_eq!(receipt.hold_ms, 21_600_000);
1183
1184        assert_eq!(
1185            decode_receipt("{\"v\":2,\"id\":\"m1\",\"hold_ms\":1}").err(),
1186            Some(ReceiptDecodeError::UnknownVersion)
1187        );
1188        assert_eq!(
1189            decode_receipt("{\"v\":1,\"id\":\"\",\"hold_ms\":1}").err(),
1190            Some(ReceiptDecodeError::EmptyId)
1191        );
1192        assert_eq!(
1193            decode_receipt("{\"id\":\"m1\",\"hold_ms\":1}").err(),
1194            Some(ReceiptDecodeError::Malformed)
1195        );
1196        assert_eq!(
1197            decode_receipt("not json").err(),
1198            Some(ReceiptDecodeError::Malformed)
1199        );
1200        // Unknown fields are ignored, as the chapter requires.
1201        assert!(decode_receipt("{\"v\":1,\"id\":\"m1\",\"hold_ms\":1,\"x\":true}").is_ok());
1202    }
1203}
1204
1205/// The frozen wire vectors for the custody receipt.
1206///
1207/// The conformance surface a second implementation is written against. When
1208/// one of these fails the wire format has changed: bump the body version and
1209/// negotiate a new `data_versions` entry. Do NOT edit the expected values to
1210/// make the test pass; every shipped install still speaks the old ones.
1211#[cfg(test)]
1212mod golden_vectors {
1213    use super::*;
1214
1215    const VECTORS: &str = include_str!("../../tests/data/custody-receipt-v1.vectors.json");
1216
1217    fn vectors() -> serde_json::Value {
1218        serde_json::from_str(VECTORS).expect("the vector file is JSON")
1219    }
1220
1221    fn cases<'a>(vectors: &'a serde_json::Value, name: &str) -> &'a Vec<serde_json::Value> {
1222        vectors[name]
1223            .as_array()
1224            .unwrap_or_else(|| panic!("{name} must be an array"))
1225    }
1226
1227    #[test]
1228    fn the_vector_file_is_the_size_it_was() {
1229        let vectors = vectors();
1230        assert_eq!(cases(&vectors, "frames").len(), 3);
1231        assert_eq!(cases(&vectors, "decode_only").len(), 2);
1232        assert_eq!(cases(&vectors, "rejects").len(), 6);
1233        assert_eq!(
1234            vectors["version"].as_u64(),
1235            Some(u64::from(CUSTODY_RECEIPT_VERSION))
1236        );
1237        assert_eq!(
1238            vectors["prefix"].as_str(),
1239            Some(internal_prefixes::CUSTODY_RECEIPT)
1240        );
1241    }
1242
1243    #[test]
1244    fn every_frame_encodes_to_its_vector_and_decodes_back() {
1245        let vectors = vectors();
1246        for case in cases(&vectors, "frames") {
1247            let name = case["name"].as_str().unwrap();
1248            let id = case["id"].as_str().unwrap();
1249            let hold_ms = case["hold_ms"].as_u64().unwrap();
1250            let wire = case["wire"].as_str().unwrap();
1251            assert_eq!(encode_receipt(id, hold_ms), wire, "{name}: encode");
1252            let body = wire
1253                .strip_prefix(internal_prefixes::CUSTODY_RECEIPT)
1254                .unwrap_or_else(|| panic!("{name}: the vector carries the prefix"));
1255            let decoded = decode_receipt(body).unwrap_or_else(|e| panic!("{name}: {e:?}"));
1256            assert_eq!(decoded.id, id, "{name}: id");
1257            assert_eq!(decoded.hold_ms, hold_ms, "{name}: hold_ms");
1258            assert_eq!(decoded.v, CUSTODY_RECEIPT_VERSION, "{name}: version");
1259        }
1260    }
1261
1262    #[test]
1263    fn every_decode_only_case_is_accepted() {
1264        let vectors = vectors();
1265        for case in cases(&vectors, "decode_only") {
1266            let name = case["name"].as_str().unwrap();
1267            let wire = case["wire"].as_str().unwrap();
1268            let body = wire
1269                .strip_prefix(internal_prefixes::CUSTODY_RECEIPT)
1270                .unwrap();
1271            let decoded = decode_receipt(body).unwrap_or_else(|e| panic!("{name}: {e:?}"));
1272            assert_eq!(decoded.id, case["id"].as_str().unwrap(), "{name}");
1273            assert_eq!(decoded.hold_ms, case["hold_ms"].as_u64().unwrap(), "{name}");
1274        }
1275    }
1276
1277    #[test]
1278    fn every_reject_case_is_refused() {
1279        let vectors = vectors();
1280        for case in cases(&vectors, "rejects") {
1281            let name = case["name"].as_str().unwrap();
1282            let wire = case["wire"].as_str().unwrap();
1283            let body = wire
1284                .strip_prefix(internal_prefixes::CUSTODY_RECEIPT)
1285                .unwrap();
1286            assert!(decode_receipt(body).is_err(), "{name} must be refused");
1287        }
1288    }
1289}