Skip to main content

chia_query/provider_registry/
registry.rs

1//! The provider registry and its two views: the fail-closed **custody** view
2//! ([`ProviderRegistry::trusted`]) and the low-trust **discovery** view
3//! ([`ProviderRegistry::any`]).
4//!
5//! The registry composes arbitrary [`ChainSourceProvider`]s (a local node, coinset.org, DIG peers,
6//! an operator override) under an operator-assigned [`TrustLevel`]. Its custody view answers a read
7//! ONLY from an operator-trusted source or a qualifying quorum, and FAILS CLOSED otherwise — a
8//! provider's self-declared `trustless` flag is advisory and never grants custody trust (SPEC §5).
9
10use chia_protocol::{Bytes32, CoinSpend};
11use dig_chainsource_interface::{
12    ChainSource, ChainSourceError, ChainSourceProvider, CoinRecord, ProviderKind, SingletonLineage,
13};
14
15/// The trust an OPERATOR assigns a provider for custody (money-routing) reads. This is the
16/// authority the custody view relies on — distinct from a provider's advisory `trustless` flag.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TrustLevel {
19    /// The operator vouches for this source for custody reads (their own verified node).
20    Trusted,
21    /// Not vouched for custody; usable only within a qualifying quorum or for discovery reads.
22    Untrusted,
23}
24
25impl TrustLevel {
26    /// The default trust for a provider kind: a local node the operator runs is `Trusted`; every
27    /// public/shared source is `Untrusted` until the operator explicitly vouches for it.
28    pub fn default_for(kind: ProviderKind) -> Self {
29        match kind {
30            ProviderKind::LocalNode => Self::Trusted,
31            ProviderKind::PublicOracle | ProviderKind::DigPeers | ProviderKind::Custom => {
32                Self::Untrusted
33            }
34        }
35    }
36}
37
38/// The number of independent groups that must agree for a pure-public quorum to satisfy custody.
39const PUBLIC_QUORUM_THRESHOLD: usize = 2;
40
41/// A boxed, dynamically-dispatched registry participant.
42type DynProvider = dyn ChainSourceProvider<Error = ChainSourceError>;
43
44/// One registered provider: the source itself, its operator-assigned trust, and the independence
45/// group it belongs to (two providers in the same group are NOT independent).
46struct Registration {
47    provider: Box<DynProvider>,
48    trust: TrustLevel,
49    independence_group: String,
50}
51
52/// Composes [`ChainSourceProvider`]s under an operator trust model, exposing a fail-closed custody
53/// view and a low-trust discovery view.
54#[derive(Default)]
55pub struct ProviderRegistry {
56    providers: Vec<Registration>,
57    allow_public_quorum_custody: bool,
58}
59
60impl ProviderRegistry {
61    /// A new, empty registry (custody fails closed until a trusted or quorum-qualifying source is
62    /// registered).
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Enables (`true`) or disables (`false`, the safe default) pure-public-quorum custody.
68    ///
69    /// With this OFF, a registry holding only public/untrusted sources cannot satisfy custody —
70    /// every custody read fails closed. Turning it ON is a deliberate operator choice that accepts
71    /// the reduced assurance of a [`PUBLIC_QUORUM_THRESHOLD`]-of-independent-groups agreement in
72    /// place of an operator-trusted source (SPEC §5).
73    pub fn allow_public_quorum_custody(mut self, allow: bool) -> Self {
74        self.allow_public_quorum_custody = allow;
75        if allow {
76            log::warn!(
77                "chia-query registry: pure-public-quorum custody ENABLED — custody reads may be \
78                 satisfied by {PUBLIC_QUORUM_THRESHOLD} independent public sources with NO \
79                 operator-trusted source. Reduced assurance vs a trusted local node."
80            );
81        }
82        self
83    }
84
85    /// Registers a provider, defaulting its trust from its [`ProviderKind`] unless the operator
86    /// overrides it, and placing it in `independence_group` (sources that could fail or lie
87    /// together — e.g. two views of the same coinset.org — share a group id).
88    pub fn register(
89        mut self,
90        provider: Box<DynProvider>,
91        trust_override: Option<TrustLevel>,
92        independence_group: impl Into<String>,
93    ) -> Self {
94        let trust = trust_override
95            .unwrap_or_else(|| TrustLevel::default_for(provider.provider_info().kind));
96        self.providers.push(Registration {
97            provider,
98            trust,
99            independence_group: independence_group.into(),
100        });
101        self
102    }
103
104    /// The CUSTODY view — sound for money-routing reads.
105    ///
106    /// A read is satisfied ONLY by an operator-[`Trusted`](TrustLevel::Trusted) source (preferred
107    /// when present) or, when `allow_public_quorum_custody` is set, a quorum of
108    /// [`PUBLIC_QUORUM_THRESHOLD`] independent public sources that agree. Otherwise it fails closed.
109    pub fn trusted(&self) -> TrustedView<'_> {
110        TrustedView { registry: self }
111    }
112
113    /// The DISCOVERY view — low-trust, single-provider reads for NON-custody use.
114    ///
115    /// It returns the first provider that answers, with NO trust or quorum guarantee. Its result
116    /// MUST NOT be used to route funds; use [`trusted`](Self::trusted) for anything custody-bearing.
117    pub fn any(&self) -> DiscoveryView<'_> {
118        DiscoveryView { registry: self }
119    }
120
121    /// The registered providers sorted by ascending `priority` (lowest tried first).
122    fn by_priority(&self) -> Vec<&Registration> {
123        let mut ordered: Vec<&Registration> = self.providers.iter().collect();
124        ordered.sort_by_key(|reg| reg.provider.provider_info().priority);
125        ordered
126    }
127}
128
129/// The custody view over a [`ProviderRegistry`] — see [`ProviderRegistry::trusted`].
130pub struct TrustedView<'a> {
131    registry: &'a ProviderRegistry,
132}
133
134impl TrustedView<'_> {
135    /// Answers `query` under the custody trust rule, failing closed when no trusted source or
136    /// qualifying quorum can answer.
137    fn custody_read<T, Q>(&self, query: Q) -> Result<T, ChainSourceError>
138    where
139        T: QuorumComparable + Clone,
140        Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
141    {
142        let trusted: Vec<&Registration> = self
143            .registry
144            .by_priority()
145            .into_iter()
146            .filter(|reg| reg.trust == TrustLevel::Trusted)
147            .collect();
148
149        // (a) An operator-trusted source is the gold standard — always preferred when present.
150        if !trusted.is_empty() {
151            let mut last_error = ChainSourceError::NoProvider;
152            for reg in trusted {
153                match query(&*reg.provider) {
154                    Ok(value) => return Ok(value),
155                    Err(error) => last_error = error,
156                }
157            }
158            // Every trusted source failed to answer — fail closed, never degrade to a public source.
159            return Err(last_error);
160        }
161
162        // (b) No trusted source. A pure-public quorum only qualifies when explicitly opted in.
163        if !self.registry.allow_public_quorum_custody {
164            return Err(ChainSourceError::NoProvider);
165        }
166        quorum_read(&self.registry.providers, PUBLIC_QUORUM_THRESHOLD, query)
167    }
168}
169
170/// The discovery view over a [`ProviderRegistry`] — see [`ProviderRegistry::any`].
171pub struct DiscoveryView<'a> {
172    registry: &'a ProviderRegistry,
173}
174
175impl DiscoveryView<'_> {
176    /// Answers `query` from the first provider that responds — NO trust or quorum guarantee.
177    fn discovery_read<T, Q>(&self, query: Q) -> Result<T, ChainSourceError>
178    where
179        Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
180    {
181        let mut last_error = ChainSourceError::NoProvider;
182        for reg in self.registry.by_priority() {
183            match query(&*reg.provider) {
184                Ok(value) => return Ok(value),
185                Err(error) => last_error = error,
186            }
187        }
188        Err(last_error)
189    }
190}
191
192/// Order-insensitive equality for quorum agreement.
193///
194/// Two honest, independent sources may return the SAME records in a DIFFERENT order — the list
195/// reads ([`ChainSource::coin_records_by_puzzle_hash`], [`ChainSource::coin_records_by_parent`])
196/// promise a set of matching coins, not an ordering. A byte-for-byte `Vec` comparison would make
197/// such sources spuriously "disagree" and fail an otherwise-satisfiable quorum (an availability
198/// nit). `quorum_eq` compares answers by VALUE independent of incidental ordering, while still
199/// treating genuinely different record SETS as disagreement — the quorum security property (SPEC §5)
200/// is preserved; only the false-negative on ordering is removed.
201trait QuorumComparable {
202    /// Whether two source answers agree for the purpose of a quorum, ignoring incidental ordering.
203    fn quorum_eq(&self, other: &Self) -> bool;
204}
205
206/// Answers with no meaningful internal ordering agree exactly when they are equal.
207macro_rules! quorum_eq_via_partial_eq {
208    ($($t:ty),+ $(,)?) => {
209        $(impl QuorumComparable for $t {
210            fn quorum_eq(&self, other: &Self) -> bool {
211                self == other
212            }
213        })+
214    };
215}
216
217quorum_eq_via_partial_eq!(
218    Option<CoinRecord>,
219    Option<CoinSpend>,
220    Option<SingletonLineage>,
221    Option<u32>,
222    Option<u64>,
223);
224
225impl QuorumComparable for Vec<CoinRecord> {
226    fn quorum_eq(&self, other: &Self) -> bool {
227        canonical_order(self) == canonical_order(other)
228    }
229}
230
231/// Borrows the records in a canonical order (by coin id) so two equal SETS returned in different
232/// orders compare equal. A coin id is a SHA-256 commitment to the whole coin, so it totally orders
233/// distinct coins; records sharing a coin but differing in metadata still differ after the sort, so
234/// genuine disagreement is preserved.
235fn canonical_order(records: &[CoinRecord]) -> Vec<&CoinRecord> {
236    let mut ordered: Vec<&CoinRecord> = records.iter().collect();
237    ordered.sort_by(|a, b| a.coin.coin_id().as_ref().cmp(b.coin.coin_id().as_ref()));
238    ordered
239}
240
241/// Requires `threshold` DISTINCT independence groups to return the SAME answer for `query`.
242///
243/// Each group contributes at most one answer (its first provider that responds); the count is over
244/// distinct groups, so two providers in the same group can never satisfy a `threshold >= 2` on
245/// their own. Agreement is order-insensitive ([`QuorumComparable`]), so two honest sources returning
246/// the same record set in a different order still agree. Insufficient agreement — disagreement, too
247/// few groups, or all errors — fails closed (SPEC §5).
248fn quorum_read<T, Q>(
249    providers: &[Registration],
250    threshold: usize,
251    query: Q,
252) -> Result<T, ChainSourceError>
253where
254    T: QuorumComparable + Clone,
255    Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
256{
257    // One representative answer per independence group (the first provider in the group to answer).
258    let mut per_group: Vec<(&str, T)> = Vec::new();
259    for reg in providers {
260        let group = reg.independence_group.as_str();
261        if per_group.iter().any(|(existing, _)| *existing == group) {
262            continue; // this group already contributed a representative answer
263        }
264        if let Ok(answer) = query(&*reg.provider) {
265            per_group.push((group, answer));
266        }
267    }
268
269    // An answer qualifies when `threshold` distinct groups agree on it (order-insensitively).
270    for (_, candidate) in &per_group {
271        let agreeing = per_group
272            .iter()
273            .filter(|(_, a)| a.quorum_eq(candidate))
274            .count();
275        if agreeing >= threshold {
276            return Ok(candidate.clone());
277        }
278    }
279    Err(ChainSourceError::NoProvider)
280}
281
282// The two views present the full [`ChainSource`] surface; each method dispatches through the view's
283// trust rule via a closure capturing the query's arguments.
284
285impl ChainSource for TrustedView<'_> {
286    type Error = ChainSourceError;
287
288    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
289        self.custody_read(move |p| p.coin_record(coin_id))
290    }
291
292    fn coin_records_by_puzzle_hash(
293        &self,
294        puzzle_hash: Bytes32,
295        include_spent: bool,
296    ) -> Result<Vec<CoinRecord>, Self::Error> {
297        self.custody_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
298    }
299
300    fn coin_records_by_parent(
301        &self,
302        parent_coin_id: Bytes32,
303    ) -> Result<Vec<CoinRecord>, Self::Error> {
304        self.custody_read(move |p| p.coin_records_by_parent(parent_coin_id))
305    }
306
307    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
308        self.custody_read(move |p| p.coin_spend(coin_id))
309    }
310
311    fn resolve_singleton_lineage(
312        &self,
313        launcher_id: Bytes32,
314    ) -> Result<Option<SingletonLineage>, Self::Error> {
315        // Cross-source agreement on a lineage requires the FULL lineage to match; consumers still
316        // apply the authority MEMBERSHIP test (`SingletonLineage::contains`) to the result — never
317        // tip/puzzle-hash equality (SPEC §5).
318        self.custody_read(move |p| p.resolve_singleton_lineage(launcher_id))
319    }
320
321    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
322        self.custody_read(|p| p.peak_height())
323    }
324
325    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
326        self.custody_read(move |p| p.block_timestamp(height))
327    }
328}
329
330impl ChainSource for DiscoveryView<'_> {
331    type Error = ChainSourceError;
332
333    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
334        self.discovery_read(move |p| p.coin_record(coin_id))
335    }
336
337    fn coin_records_by_puzzle_hash(
338        &self,
339        puzzle_hash: Bytes32,
340        include_spent: bool,
341    ) -> Result<Vec<CoinRecord>, Self::Error> {
342        self.discovery_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
343    }
344
345    fn coin_records_by_parent(
346        &self,
347        parent_coin_id: Bytes32,
348    ) -> Result<Vec<CoinRecord>, Self::Error> {
349        self.discovery_read(move |p| p.coin_records_by_parent(parent_coin_id))
350    }
351
352    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
353        self.discovery_read(move |p| p.coin_spend(coin_id))
354    }
355
356    fn resolve_singleton_lineage(
357        &self,
358        launcher_id: Bytes32,
359    ) -> Result<Option<SingletonLineage>, Self::Error> {
360        self.discovery_read(move |p| p.resolve_singleton_lineage(launcher_id))
361    }
362
363    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
364        self.discovery_read(|p| p.peak_height())
365    }
366
367    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
368        self.discovery_read(move |p| p.block_timestamp(height))
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use chia_protocol::Coin;
376    use dig_chainsource_interface::MockChainSource;
377
378    use crate::provider_registry::providers::{CoinsetProvider, CustomProvider, LocalNodeProvider};
379
380    fn coin_id(byte: u8) -> Bytes32 {
381        Coin::new(Bytes32::new([byte; 32]), Bytes32::new([byte; 32]), 1).coin_id()
382    }
383
384    fn record_for(id: Bytes32) -> CoinRecord {
385        CoinRecord {
386            coin: Coin::new(id, Bytes32::new([0x22; 32]), 1),
387            confirmed_height: Some(100),
388            spent_height: None,
389            timestamp: Some(1_700_000_000),
390            coinbase: false,
391        }
392    }
393
394    fn mock_with(id: Bytes32) -> MockChainSource {
395        MockChainSource::new().with_coin(id, record_for(id))
396    }
397
398    // ---- Test #2 (LOAD-BEARING): pure-public quorum WITHOUT opt-in fails closed ----
399
400    #[test]
401    fn pure_public_quorum_without_optin_fails_closed_for_custody() {
402        let id = coin_id(0x01);
403        // Two INDEPENDENT public sources that both KNOW the coin and AGREE — yet, with no
404        // operator-trusted source and no opt-in, custody MUST NOT be satisfied.
405        let registry = ProviderRegistry::new()
406            .register(
407                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
408                None,
409                "coinset.org",
410            )
411            .register(
412                Box::new(CustomProvider::new("mirror-b", 20, mock_with(id))),
413                None,
414                "mirror.example",
415            );
416
417        let result = registry.trusted().coin_record(id);
418        assert_eq!(
419            result,
420            Err(ChainSourceError::NoProvider),
421            "pure-public custody must fail closed without allow_public_quorum_custody"
422        );
423    }
424
425    // ---- Test #3: an operator-Trusted LocalNode satisfies custody ----
426
427    #[test]
428    fn operator_trusted_local_node_satisfies_custody() {
429        let id = coin_id(0x02);
430        let registry = ProviderRegistry::new().register(
431            Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
432            None, // LocalNode defaults to Trusted
433            "local-node",
434        );
435
436        let record = registry.trusted().coin_record(id).unwrap();
437        assert_eq!(record, Some(record_for(id)));
438    }
439
440    #[test]
441    fn local_node_defaults_to_trusted() {
442        assert_eq!(
443            TrustLevel::default_for(ProviderKind::LocalNode),
444            TrustLevel::Trusted
445        );
446        assert_eq!(
447            TrustLevel::default_for(ProviderKind::PublicOracle),
448            TrustLevel::Untrusted
449        );
450    }
451
452    // ---- Test #4: a quorum INCLUDING a trusted member satisfies custody ----
453
454    #[test]
455    fn quorum_including_trusted_member_satisfies_custody() {
456        let id = coin_id(0x03);
457        let registry = ProviderRegistry::new()
458            .register(
459                Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
460                None, // Trusted
461                "local-node",
462            )
463            .register(
464                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
465                None, // Untrusted public
466                "coinset.org",
467            );
468
469        // The trusted member answers; custody is satisfied.
470        let record = registry.trusted().coin_record(id).unwrap();
471        assert_eq!(record, Some(record_for(id)));
472    }
473
474    #[test]
475    fn trusted_source_error_fails_closed_not_public_fallback() {
476        let id = coin_id(0x04);
477        let registry = ProviderRegistry::new()
478            .register(
479                Box::new(LocalNodeProvider::new(
480                    "local",
481                    0,
482                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
483                )),
484                None,
485                "local-node",
486            )
487            .register(
488                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
489                None,
490                "coinset.org",
491            );
492
493        // The trusted node errored; custody fails closed rather than reading the public source.
494        assert_eq!(
495            registry.trusted().coin_record(id),
496            Err(ChainSourceError::Timeout)
497        );
498    }
499
500    // ---- Test #5: opt-in pure-public quorum needs T=2 INDEPENDENT groups ----
501
502    #[test]
503    fn optin_two_independent_groups_agree_satisfies_custody() {
504        let id = coin_id(0x05);
505        let registry = ProviderRegistry::new()
506            .allow_public_quorum_custody(true)
507            .register(
508                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
509                None,
510                "coinset.org",
511            )
512            .register(
513                Box::new(CustomProvider::new("mirror", 20, mock_with(id))),
514                None,
515                "mirror.example",
516            );
517
518        assert_eq!(
519            registry.trusted().coin_record(id).unwrap(),
520            Some(record_for(id))
521        );
522    }
523
524    #[test]
525    fn optin_single_group_fails_closed() {
526        let id = coin_id(0x06);
527        // Only ONE independent group qualifies -> below threshold -> fail closed.
528        let registry = ProviderRegistry::new()
529            .allow_public_quorum_custody(true)
530            .register(
531                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
532                None,
533                "coinset.org",
534            );
535
536        assert_eq!(
537            registry.trusted().coin_record(id),
538            Err(ChainSourceError::NoProvider)
539        );
540    }
541
542    #[test]
543    fn optin_two_providers_same_group_fails_closed() {
544        let id = coin_id(0x07);
545        // Two providers agreeing but in the SAME independence group count as ONE -> below T=2.
546        let registry = ProviderRegistry::new()
547            .allow_public_quorum_custody(true)
548            .register(
549                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
550                None,
551                "coinset.org",
552            )
553            .register(
554                Box::new(CoinsetProvider::new("coinset-b", 20, mock_with(id))),
555                None,
556                "coinset.org", // SAME group
557            );
558
559        assert_eq!(
560            registry.trusted().coin_record(id),
561            Err(ChainSourceError::NoProvider)
562        );
563    }
564
565    #[test]
566    fn optin_two_groups_disagree_fails_closed() {
567        let id = coin_id(0x08);
568        // Two independent groups that DISAGREE (one knows the coin, one does not) -> no quorum.
569        let registry = ProviderRegistry::new()
570            .allow_public_quorum_custody(true)
571            .register(
572                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
573                None,
574                "coinset.org",
575            )
576            .register(
577                Box::new(CustomProvider::new("empty", 20, MockChainSource::new())),
578                None,
579                "mirror.example",
580            );
581
582        // coinset says Some(record), mirror says Ok(None) — disagreement -> fail closed.
583        assert_eq!(
584            registry.trusted().coin_record(id),
585            Err(ChainSourceError::NoProvider)
586        );
587    }
588
589    // ---- Order-insensitive quorum agreement (#1259 fix 1) ----
590
591    /// A test source returning a fixed, caller-controlled ORDER of records for the list reads, so a
592    /// quorum comparison can be exercised across sources that agree on the SET but differ in order.
593    struct FixedListSource {
594        records: Vec<CoinRecord>,
595    }
596
597    impl ChainSource for FixedListSource {
598        type Error = ChainSourceError;
599
600        fn coin_record(&self, _coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
601            Ok(None)
602        }
603
604        fn coin_records_by_puzzle_hash(
605            &self,
606            _puzzle_hash: Bytes32,
607            _include_spent: bool,
608        ) -> Result<Vec<CoinRecord>, Self::Error> {
609            Ok(self.records.clone())
610        }
611
612        fn coin_records_by_parent(
613            &self,
614            _parent_coin_id: Bytes32,
615        ) -> Result<Vec<CoinRecord>, Self::Error> {
616            Ok(self.records.clone())
617        }
618
619        fn coin_spend(&self, _coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
620            Ok(None)
621        }
622
623        fn resolve_singleton_lineage(
624            &self,
625            _launcher_id: Bytes32,
626        ) -> Result<Option<SingletonLineage>, Self::Error> {
627            Ok(None)
628        }
629
630        fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
631            Ok(None)
632        }
633
634        fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
635            Ok(None)
636        }
637    }
638
639    fn list_source(records: Vec<CoinRecord>) -> FixedListSource {
640        FixedListSource { records }
641    }
642
643    #[test]
644    fn quorum_agrees_on_same_record_set_in_different_order() {
645        let ph = Bytes32::new([0x22; 32]);
646        let a = record_for(coin_id(0x0A));
647        let b = record_for(coin_id(0x0B));
648
649        // Two INDEPENDENT public sources return the SAME set of records in REVERSED order. With the
650        // opt-in quorum, they must AGREE (order is not a source of disagreement).
651        let registry = ProviderRegistry::new()
652            .allow_public_quorum_custody(true)
653            .register(
654                Box::new(CoinsetProvider::new(
655                    "coinset",
656                    10,
657                    list_source(vec![a.clone(), b.clone()]),
658                )),
659                None,
660                "coinset.org",
661            )
662            .register(
663                Box::new(CustomProvider::new(
664                    "mirror",
665                    20,
666                    list_source(vec![b.clone(), a.clone()]),
667                )),
668                None,
669                "mirror.example",
670            );
671
672        let records = registry
673            .trusted()
674            .coin_records_by_puzzle_hash(ph, false)
675            .expect("honest sources agreeing on a set must satisfy the quorum");
676        assert_eq!(records.len(), 2);
677        assert!(records.contains(&a) && records.contains(&b));
678    }
679
680    #[test]
681    fn quorum_still_fails_closed_on_genuinely_different_record_sets() {
682        let ph = Bytes32::new([0x22; 32]);
683        let a = record_for(coin_id(0x0A));
684        let b = record_for(coin_id(0x0B));
685        let c = record_for(coin_id(0x0C));
686
687        // Different SETS ({a,b} vs {a,c}) must still be treated as disagreement -> fail closed. The
688        // order-insensitive comparison must not weaken the quorum into accepting different content.
689        let registry = ProviderRegistry::new()
690            .allow_public_quorum_custody(true)
691            .register(
692                Box::new(CoinsetProvider::new(
693                    "coinset",
694                    10,
695                    list_source(vec![a.clone(), b]),
696                )),
697                None,
698                "coinset.org",
699            )
700            .register(
701                Box::new(CustomProvider::new("mirror", 20, list_source(vec![c, a]))),
702                None,
703                "mirror.example",
704            );
705
706        assert_eq!(
707            registry.trusted().coin_records_by_puzzle_hash(ph, false),
708            Err(ChainSourceError::NoProvider),
709            "genuinely different record sets must fail closed"
710        );
711    }
712
713    // ---- Test #7: discovery view returns a single-provider answer, inert for custody ----
714
715    #[test]
716    fn discovery_view_returns_single_provider_answer() {
717        let id = coin_id(0x09);
718        let registry = ProviderRegistry::new().register(
719            Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
720            None,
721            "coinset.org",
722        );
723
724        // Discovery answers from the single public provider...
725        assert_eq!(
726            registry.any().coin_record(id).unwrap(),
727            Some(record_for(id))
728        );
729        // ...but the SAME registry fails closed for custody (no trusted source, no opt-in).
730        assert_eq!(
731            registry.trusted().coin_record(id),
732            Err(ChainSourceError::NoProvider)
733        );
734    }
735
736    #[test]
737    fn every_read_method_flows_through_both_views() {
738        let ph = Bytes32::new([0x22; 32]);
739        let parent = Coin::new(Bytes32::new([0x01; 32]), ph, 1);
740        let parent_id = parent.coin_id();
741        let child = Coin::new(parent_id, ph, 1);
742        let launcher = Bytes32::new([0x77; 32]);
743
744        let source = MockChainSource::new()
745            .with_coin(parent_id, record_for(parent_id))
746            .with_coin(child.coin_id(), {
747                let mut r = record_for(child.coin_id());
748                r.coin = child;
749                r
750            })
751            .with_spend(
752                parent_id,
753                chia_protocol::CoinSpend::new(
754                    parent,
755                    chia_protocol::Program::from(vec![1]),
756                    chia_protocol::Program::from(vec![0x80]),
757                ),
758            )
759            .with_lineage(launcher, SingletonLineage::single(launcher))
760            .with_timestamp(100, 1_700_000_000)
761            .with_peak(555);
762
763        let registry = ProviderRegistry::new().register(
764            Box::new(LocalNodeProvider::new("local", 0, source)),
765            None, // Trusted
766            "local-node",
767        );
768
769        // Custody view — every method answers via the trusted source.
770        let custody = registry.trusted();
771        assert!(!custody
772            .coin_records_by_puzzle_hash(ph, true)
773            .unwrap()
774            .is_empty());
775        assert!(!custody
776            .coin_records_by_parent(parent_id)
777            .unwrap()
778            .is_empty());
779        assert!(custody.coin_spend(parent_id).unwrap().is_some());
780        assert_eq!(custody.peak_height().unwrap(), Some(555));
781        assert_eq!(custody.block_timestamp(100).unwrap(), Some(1_700_000_000));
782        assert_eq!(
783            custody.resolve_singleton_lineage(launcher).unwrap(),
784            Some(SingletonLineage::single(launcher))
785        );
786
787        // Discovery view — same reads, single-provider, no trust guarantee.
788        let discovery = registry.any();
789        assert!(discovery.coin_record(parent_id).unwrap().is_some());
790        assert!(!discovery
791            .coin_records_by_puzzle_hash(ph, false)
792            .unwrap()
793            .is_empty());
794        assert!(!discovery
795            .coin_records_by_parent(parent_id)
796            .unwrap()
797            .is_empty());
798        assert!(discovery.coin_spend(parent_id).unwrap().is_some());
799        assert_eq!(discovery.peak_height().unwrap(), Some(555));
800        assert_eq!(discovery.block_timestamp(100).unwrap(), Some(1_700_000_000));
801        assert!(discovery
802            .resolve_singleton_lineage(launcher)
803            .unwrap()
804            .is_some());
805    }
806
807    #[test]
808    fn discovery_falls_through_failing_providers_to_a_responder() {
809        let id = coin_id(0x0B);
810        let registry = ProviderRegistry::new()
811            .register(
812                Box::new(CoinsetProvider::new(
813                    "down",
814                    0,
815                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
816                )),
817                None,
818                "down",
819            )
820            .register(
821                Box::new(CoinsetProvider::new("up", 10, mock_with(id))),
822                None,
823                "up",
824            );
825        assert_eq!(
826            registry.any().coin_record(id).unwrap(),
827            Some(record_for(id))
828        );
829    }
830
831    #[test]
832    fn empty_registry_fails_closed_everywhere() {
833        let registry = ProviderRegistry::new();
834        let id = coin_id(0x0A);
835        assert_eq!(
836            registry.trusted().coin_record(id),
837            Err(ChainSourceError::NoProvider)
838        );
839        assert_eq!(
840            registry.any().coin_record(id),
841            Err(ChainSourceError::NoProvider)
842        );
843    }
844}