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