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::TooManyRecords`]) 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    ///
190    /// Even without a quorum, a discovery answer is still UNTRUSTED input, so each candidate is
191    /// bounded ([`QuorumComparable::validate_bound`]) before it is accepted — mirroring the quorum
192    /// path (#1341), which drops an over-cap member rather than aborting. An oversized answer here is
193    /// likewise treated as a non-answer: it records the bound violation in `last_error` and falls
194    /// through to the next provider, so one hostile source returning a multi-GB flood can neither be
195    /// returned to the caller nor deny an honest fallback provider its turn.
196    fn discovery_read<T, Q>(&self, query: Q) -> Result<T, ChainSourceError>
197    where
198        T: QuorumComparable,
199        Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
200    {
201        let mut last_error = ChainSourceError::NoProvider;
202        for reg in self.registry.by_priority() {
203            match query(&*reg.provider) {
204                Ok(value) => match value.validate_bound() {
205                    Ok(()) => return Ok(value),
206                    Err(error) => last_error = error,
207                },
208                Err(error) => last_error = error,
209            }
210        }
211        Err(last_error)
212    }
213}
214
215/// Order-insensitive equality for quorum agreement.
216///
217/// Two honest, independent sources may return the SAME records in a DIFFERENT order — the list
218/// reads ([`ChainSource::coin_records_by_puzzle_hash`], [`ChainSource::coin_records_by_parent`])
219/// promise a set of matching coins, not an ordering. A byte-for-byte `Vec` comparison would make
220/// such sources spuriously "disagree" and fail an otherwise-satisfiable quorum (an availability
221/// nit). `quorum_eq` compares answers by VALUE independent of incidental ordering, while still
222/// treating genuinely different record SETS as disagreement — the quorum security property (SPEC §5)
223/// is preserved; only the false-negative on ordering is removed.
224trait QuorumComparable {
225    /// Whether two source answers agree for the purpose of a quorum, ignoring incidental ordering.
226    fn quorum_eq(&self, other: &Self) -> bool;
227
228    /// Rejects an implausibly large answer from an untrusted source BEFORE it is canonicalized or
229    /// compared, bounding quorum CPU + memory. The default imposes no bound (fixed-size answers);
230    /// only unbounded list answers override it.
231    fn validate_bound(&self) -> Result<(), ChainSourceError> {
232        Ok(())
233    }
234}
235
236/// Answers with no meaningful internal ordering agree exactly when they are equal.
237macro_rules! quorum_eq_via_partial_eq {
238    ($($t:ty),+ $(,)?) => {
239        $(impl QuorumComparable for $t {
240            fn quorum_eq(&self, other: &Self) -> bool {
241                self == other
242            }
243        })+
244    };
245}
246
247quorum_eq_via_partial_eq!(
248    Option<CoinRecord>,
249    Option<CoinSpend>,
250    Option<SingletonLineage>,
251    Option<u32>,
252    Option<u64>,
253);
254
255impl QuorumComparable for Vec<CoinRecord> {
256    fn quorum_eq(&self, other: &Self) -> bool {
257        canonical_order(self) == canonical_order(other)
258    }
259
260    fn validate_bound(&self) -> Result<(), ChainSourceError> {
261        if self.len() > MAX_QUORUM_RECORDS {
262            return Err(ChainSourceError::TooManyRecords {
263                count: self.len(),
264                limit: MAX_QUORUM_RECORDS,
265            });
266        }
267        Ok(())
268    }
269}
270
271/// Borrows the records in a canonical order keyed by coin id so two equal SETS returned in different
272/// orders compare equal.
273///
274/// Each record's coin id is a SHA-256 commitment to the whole coin, computed ONCE per record into the
275/// sort key here — so the comparison hashes O(n) times, not the O(n log n) a `sort_by` recomputing
276/// `coin_id()` in every comparison would cost. The id totally orders distinct coins; records sharing
277/// a coin but differing in metadata still differ (the comparison includes the record), so genuine
278/// disagreement is preserved.
279fn canonical_order(records: &[CoinRecord]) -> Vec<(Bytes32, &CoinRecord)> {
280    let mut keyed: Vec<(Bytes32, &CoinRecord)> =
281        records.iter().map(|r| (r.coin.coin_id(), r)).collect();
282    keyed.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
283    keyed
284}
285
286/// Requires `threshold` DISTINCT independence groups to return the SAME answer for `query`.
287///
288/// Each group contributes at most one answer (its first provider that responds); the count is over
289/// distinct groups, so two providers in the same group can never satisfy a `threshold >= 2` on
290/// their own. Agreement is order-insensitive ([`QuorumComparable`]), so two honest sources returning
291/// the same record set in a different order still agree. Insufficient agreement — disagreement, too
292/// few groups, or all errors — fails closed (SPEC §5).
293fn quorum_read<T, Q>(
294    providers: &[Registration],
295    threshold: usize,
296    query: Q,
297) -> Result<T, ChainSourceError>
298where
299    T: QuorumComparable + Clone,
300    Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
301{
302    // One representative answer per independence group (the first provider in the group to answer).
303    let mut per_group: Vec<(&str, T)> = Vec::new();
304    for reg in providers {
305        let group = reg.independence_group.as_str();
306        if per_group.iter().any(|(existing, _)| *existing == group) {
307            continue; // this group already contributed a representative answer
308        }
309        if let Ok(answer) = query(&*reg.provider) {
310            // Drop an implausibly large untrusted answer BEFORE any canonicalization/comparison, so
311            // it costs no O(n log n) work and never enters the tally. Skipping (not propagating) is
312            // deliberate: a single hostile member returning an oversized list simply loses its vote,
313            // exactly as a non-responding member would — it must NOT abort the whole quorum and deny
314            // an otherwise-valid honest agreement (that would be a self-defeating DoS lever).
315            if answer.validate_bound().is_err() {
316                continue;
317            }
318            per_group.push((group, answer));
319        }
320    }
321
322    // An answer qualifies when `threshold` distinct groups agree on it (order-insensitively).
323    for (_, candidate) in &per_group {
324        let agreeing = per_group
325            .iter()
326            .filter(|(_, a)| a.quorum_eq(candidate))
327            .count();
328        if agreeing >= threshold {
329            return Ok(candidate.clone());
330        }
331    }
332    Err(ChainSourceError::NoProvider)
333}
334
335// The two views present the full [`ChainSource`] surface; each method dispatches through the view's
336// trust rule via a closure capturing the query's arguments.
337
338impl ChainSource for TrustedView<'_> {
339    type Error = ChainSourceError;
340
341    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
342        self.custody_read(move |p| p.coin_record(coin_id))
343    }
344
345    fn coin_records_by_puzzle_hash(
346        &self,
347        puzzle_hash: Bytes32,
348        include_spent: bool,
349    ) -> Result<Vec<CoinRecord>, Self::Error> {
350        self.custody_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
351    }
352
353    fn coin_records_by_parent(
354        &self,
355        parent_coin_id: Bytes32,
356    ) -> Result<Vec<CoinRecord>, Self::Error> {
357        self.custody_read(move |p| p.coin_records_by_parent(parent_coin_id))
358    }
359
360    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
361        self.custody_read(move |p| p.coin_spend(coin_id))
362    }
363
364    fn resolve_singleton_lineage(
365        &self,
366        launcher_id: Bytes32,
367    ) -> Result<Option<SingletonLineage>, Self::Error> {
368        // Cross-source agreement on a lineage requires the FULL lineage to match; consumers still
369        // apply the authority MEMBERSHIP test (`SingletonLineage::contains`) to the result — never
370        // tip/puzzle-hash equality (SPEC §5).
371        self.custody_read(move |p| p.resolve_singleton_lineage(launcher_id))
372    }
373
374    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
375        self.custody_read(|p| p.peak_height())
376    }
377
378    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
379        self.custody_read(move |p| p.block_timestamp(height))
380    }
381}
382
383impl ChainSource for DiscoveryView<'_> {
384    type Error = ChainSourceError;
385
386    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
387        self.discovery_read(move |p| p.coin_record(coin_id))
388    }
389
390    fn coin_records_by_puzzle_hash(
391        &self,
392        puzzle_hash: Bytes32,
393        include_spent: bool,
394    ) -> Result<Vec<CoinRecord>, Self::Error> {
395        self.discovery_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
396    }
397
398    fn coin_records_by_parent(
399        &self,
400        parent_coin_id: Bytes32,
401    ) -> Result<Vec<CoinRecord>, Self::Error> {
402        self.discovery_read(move |p| p.coin_records_by_parent(parent_coin_id))
403    }
404
405    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
406        self.discovery_read(move |p| p.coin_spend(coin_id))
407    }
408
409    fn resolve_singleton_lineage(
410        &self,
411        launcher_id: Bytes32,
412    ) -> Result<Option<SingletonLineage>, Self::Error> {
413        self.discovery_read(move |p| p.resolve_singleton_lineage(launcher_id))
414    }
415
416    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
417        self.discovery_read(|p| p.peak_height())
418    }
419
420    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
421        self.discovery_read(move |p| p.block_timestamp(height))
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use chia_protocol::Coin;
429    use dig_chainsource_interface::MockChainSource;
430
431    use crate::provider_registry::providers::{CoinsetProvider, CustomProvider, LocalNodeProvider};
432
433    fn coin_id(byte: u8) -> Bytes32 {
434        Coin::new(Bytes32::new([byte; 32]), Bytes32::new([byte; 32]), 1).coin_id()
435    }
436
437    fn record_for(id: Bytes32) -> CoinRecord {
438        CoinRecord {
439            coin: Coin::new(id, Bytes32::new([0x22; 32]), 1),
440            confirmed_height: Some(100),
441            spent_height: None,
442            timestamp: Some(1_700_000_000),
443            coinbase: false,
444        }
445    }
446
447    fn mock_with(id: Bytes32) -> MockChainSource {
448        MockChainSource::new().with_coin(id, record_for(id))
449    }
450
451    // ---- Test #2 (LOAD-BEARING): pure-public quorum WITHOUT opt-in fails closed ----
452
453    #[test]
454    fn pure_public_quorum_without_optin_fails_closed_for_custody() {
455        let id = coin_id(0x01);
456        // Two INDEPENDENT public sources that both KNOW the coin and AGREE — yet, with no
457        // operator-trusted source and no opt-in, custody MUST NOT be satisfied.
458        let registry = ProviderRegistry::new()
459            .register(
460                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
461                None,
462                "coinset.org",
463            )
464            .register(
465                Box::new(CustomProvider::new("mirror-b", 20, mock_with(id))),
466                None,
467                "mirror.example",
468            );
469
470        let result = registry.trusted().coin_record(id);
471        assert_eq!(
472            result,
473            Err(ChainSourceError::NoProvider),
474            "pure-public custody must fail closed without allow_public_quorum_custody"
475        );
476    }
477
478    // ---- Test #3: an operator-Trusted LocalNode satisfies custody ----
479
480    #[test]
481    fn operator_trusted_local_node_satisfies_custody() {
482        let id = coin_id(0x02);
483        let registry = ProviderRegistry::new().register(
484            Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
485            None, // LocalNode defaults to Trusted
486            "local-node",
487        );
488
489        let record = registry.trusted().coin_record(id).unwrap();
490        assert_eq!(record, Some(record_for(id)));
491    }
492
493    #[test]
494    fn local_node_defaults_to_trusted() {
495        assert_eq!(
496            TrustLevel::default_for(ProviderKind::LocalNode),
497            TrustLevel::Trusted
498        );
499        assert_eq!(
500            TrustLevel::default_for(ProviderKind::PublicOracle),
501            TrustLevel::Untrusted
502        );
503    }
504
505    // ---- Test #4: a quorum INCLUDING a trusted member satisfies custody ----
506
507    #[test]
508    fn quorum_including_trusted_member_satisfies_custody() {
509        let id = coin_id(0x03);
510        let registry = ProviderRegistry::new()
511            .register(
512                Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
513                None, // Trusted
514                "local-node",
515            )
516            .register(
517                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
518                None, // Untrusted public
519                "coinset.org",
520            );
521
522        // The trusted member answers; custody is satisfied.
523        let record = registry.trusted().coin_record(id).unwrap();
524        assert_eq!(record, Some(record_for(id)));
525    }
526
527    #[test]
528    fn trusted_source_error_fails_closed_not_public_fallback() {
529        let id = coin_id(0x04);
530        let registry = ProviderRegistry::new()
531            .register(
532                Box::new(LocalNodeProvider::new(
533                    "local",
534                    0,
535                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
536                )),
537                None,
538                "local-node",
539            )
540            .register(
541                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
542                None,
543                "coinset.org",
544            );
545
546        // The trusted node errored; custody fails closed rather than reading the public source.
547        assert_eq!(
548            registry.trusted().coin_record(id),
549            Err(ChainSourceError::Timeout)
550        );
551    }
552
553    // ---- Test #5: opt-in pure-public quorum needs T=2 INDEPENDENT groups ----
554
555    #[test]
556    fn optin_two_independent_groups_agree_satisfies_custody() {
557        let id = coin_id(0x05);
558        let registry = ProviderRegistry::new()
559            .allow_public_quorum_custody(true)
560            .register(
561                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
562                None,
563                "coinset.org",
564            )
565            .register(
566                Box::new(CustomProvider::new("mirror", 20, mock_with(id))),
567                None,
568                "mirror.example",
569            );
570
571        assert_eq!(
572            registry.trusted().coin_record(id).unwrap(),
573            Some(record_for(id))
574        );
575    }
576
577    #[test]
578    fn optin_single_group_fails_closed() {
579        let id = coin_id(0x06);
580        // Only ONE independent group qualifies -> below threshold -> fail closed.
581        let registry = ProviderRegistry::new()
582            .allow_public_quorum_custody(true)
583            .register(
584                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
585                None,
586                "coinset.org",
587            );
588
589        assert_eq!(
590            registry.trusted().coin_record(id),
591            Err(ChainSourceError::NoProvider)
592        );
593    }
594
595    #[test]
596    fn optin_two_providers_same_group_fails_closed() {
597        let id = coin_id(0x07);
598        // Two providers agreeing but in the SAME independence group count as ONE -> below T=2.
599        let registry = ProviderRegistry::new()
600            .allow_public_quorum_custody(true)
601            .register(
602                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
603                None,
604                "coinset.org",
605            )
606            .register(
607                Box::new(CoinsetProvider::new("coinset-b", 20, mock_with(id))),
608                None,
609                "coinset.org", // SAME group
610            );
611
612        assert_eq!(
613            registry.trusted().coin_record(id),
614            Err(ChainSourceError::NoProvider)
615        );
616    }
617
618    #[test]
619    fn optin_two_groups_disagree_fails_closed() {
620        let id = coin_id(0x08);
621        // Two independent groups that DISAGREE (one knows the coin, one does not) -> no quorum.
622        let registry = ProviderRegistry::new()
623            .allow_public_quorum_custody(true)
624            .register(
625                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
626                None,
627                "coinset.org",
628            )
629            .register(
630                Box::new(CustomProvider::new("empty", 20, MockChainSource::new())),
631                None,
632                "mirror.example",
633            );
634
635        // coinset says Some(record), mirror says Ok(None) — disagreement -> fail closed.
636        assert_eq!(
637            registry.trusted().coin_record(id),
638            Err(ChainSourceError::NoProvider)
639        );
640    }
641
642    // ---- Order-insensitive quorum agreement (#1259 fix 1) ----
643
644    /// A test source returning a fixed, caller-controlled ORDER of records for the list reads, so a
645    /// quorum comparison can be exercised across sources that agree on the SET but differ in order.
646    struct FixedListSource {
647        records: Vec<CoinRecord>,
648    }
649
650    impl ChainSource for FixedListSource {
651        type Error = ChainSourceError;
652
653        fn coin_record(&self, _coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
654            Ok(None)
655        }
656
657        fn coin_records_by_puzzle_hash(
658            &self,
659            _puzzle_hash: Bytes32,
660            _include_spent: bool,
661        ) -> Result<Vec<CoinRecord>, Self::Error> {
662            Ok(self.records.clone())
663        }
664
665        fn coin_records_by_parent(
666            &self,
667            _parent_coin_id: Bytes32,
668        ) -> Result<Vec<CoinRecord>, Self::Error> {
669            Ok(self.records.clone())
670        }
671
672        fn coin_spend(&self, _coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
673            Ok(None)
674        }
675
676        fn resolve_singleton_lineage(
677            &self,
678            _launcher_id: Bytes32,
679        ) -> Result<Option<SingletonLineage>, Self::Error> {
680            Ok(None)
681        }
682
683        fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
684            Ok(None)
685        }
686
687        fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
688            Ok(None)
689        }
690    }
691
692    fn list_source(records: Vec<CoinRecord>) -> FixedListSource {
693        FixedListSource { records }
694    }
695
696    #[test]
697    fn quorum_agrees_on_same_record_set_in_different_order() {
698        let ph = Bytes32::new([0x22; 32]);
699        let a = record_for(coin_id(0x0A));
700        let b = record_for(coin_id(0x0B));
701
702        // Two INDEPENDENT public sources return the SAME set of records in REVERSED order. With the
703        // opt-in quorum, they must AGREE (order is not a source of disagreement).
704        let registry = ProviderRegistry::new()
705            .allow_public_quorum_custody(true)
706            .register(
707                Box::new(CoinsetProvider::new(
708                    "coinset",
709                    10,
710                    list_source(vec![a.clone(), b.clone()]),
711                )),
712                None,
713                "coinset.org",
714            )
715            .register(
716                Box::new(CustomProvider::new(
717                    "mirror",
718                    20,
719                    list_source(vec![b.clone(), a.clone()]),
720                )),
721                None,
722                "mirror.example",
723            );
724
725        let records = registry
726            .trusted()
727            .coin_records_by_puzzle_hash(ph, false)
728            .expect("honest sources agreeing on a set must satisfy the quorum");
729        assert_eq!(records.len(), 2);
730        assert!(records.contains(&a) && records.contains(&b));
731    }
732
733    #[test]
734    fn quorum_still_fails_closed_on_genuinely_different_record_sets() {
735        let ph = Bytes32::new([0x22; 32]);
736        let a = record_for(coin_id(0x0A));
737        let b = record_for(coin_id(0x0B));
738        let c = record_for(coin_id(0x0C));
739
740        // Different SETS ({a,b} vs {a,c}) must still be treated as disagreement -> fail closed. The
741        // order-insensitive comparison must not weaken the quorum into accepting different content.
742        let registry = ProviderRegistry::new()
743            .allow_public_quorum_custody(true)
744            .register(
745                Box::new(CoinsetProvider::new(
746                    "coinset",
747                    10,
748                    list_source(vec![a.clone(), b]),
749                )),
750                None,
751                "coinset.org",
752            )
753            .register(
754                Box::new(CustomProvider::new("mirror", 20, list_source(vec![c, a]))),
755                None,
756                "mirror.example",
757            );
758
759        assert_eq!(
760            registry.trusted().coin_records_by_puzzle_hash(ph, false),
761            Err(ChainSourceError::NoProvider),
762            "genuinely different record sets must fail closed"
763        );
764    }
765
766    /// Builds an over-cap flood of records (all under puzzle hash `ph`) to exercise the record bound.
767    fn oversized_flood(ph: Bytes32) -> Vec<CoinRecord> {
768        let flood: Vec<CoinRecord> = (0..=MAX_QUORUM_RECORDS as u32)
769            .map(|i| {
770                let coin = Coin::new(Bytes32::new([0x01; 32]), ph, u64::from(i) + 1);
771                let mut r = record_for(coin.coin_id());
772                r.coin = coin;
773                r
774            })
775            .collect();
776        assert!(flood.len() > MAX_QUORUM_RECORDS);
777        flood
778    }
779
780    /// #1341: an over-cap untrusted answer is DROPPED (loses its vote), never propagated. When every
781    /// available source floods, no honest quorum can form -> fail closed (as a set of non-responders
782    /// would), but the bound work is still avoided (the flood is never canonicalized).
783    #[test]
784    fn quorum_fails_closed_when_all_sources_exceed_the_record_cap() {
785        let ph = Bytes32::new([0x22; 32]);
786        let flood = oversized_flood(ph);
787
788        let registry = ProviderRegistry::new()
789            .allow_public_quorum_custody(true)
790            .register(
791                Box::new(CoinsetProvider::new(
792                    "coinset",
793                    10,
794                    list_source(flood.clone()),
795                )),
796                None,
797                "coinset.org",
798            )
799            .register(
800                Box::new(CustomProvider::new("mirror", 20, list_source(flood))),
801                None,
802                "mirror.example",
803            );
804
805        // Both oversized answers are skipped -> no group votes -> no quorum -> fail closed.
806        assert_eq!(
807            registry.trusted().coin_records_by_puzzle_hash(ph, false),
808            Err(ChainSourceError::NoProvider),
809            "when every source floods, custody must fail closed"
810        );
811    }
812
813    /// #1341 (in-PR security fix): a SINGLE hostile member returning an over-cap flood must NOT abort
814    /// the whole quorum. It loses its vote (dropped before any canonicalization); the two honest,
815    /// independent groups that agree still form the quorum, so availability is preserved while the
816    /// DoS bound holds.
817    #[test]
818    fn oversized_quorum_member_is_skipped_not_fatal() {
819        let ph = Bytes32::new([0x22; 32]);
820        let a = record_for(coin_id(0x0A));
821        let b = record_for(coin_id(0x0B));
822        let flood = oversized_flood(ph);
823
824        let registry = ProviderRegistry::new()
825            .allow_public_quorum_custody(true)
826            .register(
827                Box::new(CoinsetProvider::new(
828                    "coinset",
829                    10,
830                    list_source(vec![a.clone(), b.clone()]),
831                )),
832                None,
833                "coinset.org",
834            )
835            .register(
836                Box::new(CustomProvider::new(
837                    "mirror",
838                    20,
839                    list_source(vec![b.clone(), a.clone()]),
840                )),
841                None,
842                "mirror.example",
843            )
844            .register(
845                Box::new(CustomProvider::new("flooder", 30, list_source(flood))),
846                None,
847                "flooder.example",
848            );
849
850        let records = registry
851            .trusted()
852            .coin_records_by_puzzle_hash(ph, false)
853            .expect("an honest 2-group agreement must still satisfy the quorum");
854        assert_eq!(records.len(), 2, "the flood must not enter the tally");
855        assert!(records.contains(&a) && records.contains(&b));
856    }
857
858    // ---- Test #7: discovery view returns a single-provider answer, inert for custody ----
859
860    #[test]
861    fn discovery_view_returns_single_provider_answer() {
862        let id = coin_id(0x09);
863        let registry = ProviderRegistry::new().register(
864            Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
865            None,
866            "coinset.org",
867        );
868
869        // Discovery answers from the single public provider...
870        assert_eq!(
871            registry.any().coin_record(id).unwrap(),
872            Some(record_for(id))
873        );
874        // ...but the SAME registry fails closed for custody (no trusted source, no opt-in).
875        assert_eq!(
876            registry.trusted().coin_record(id),
877            Err(ChainSourceError::NoProvider)
878        );
879    }
880
881    /// #1351: a discovery answer is untrusted too — an over-cap flood from the only provider must be
882    /// rejected (`TooManyRecords`), never handed back to the caller unbounded.
883    #[test]
884    fn discovery_rejects_oversized_untrusted_response() {
885        let ph = Bytes32::new([0x22; 32]);
886        let flood = oversized_flood(ph);
887
888        let registry = ProviderRegistry::new().register(
889            Box::new(CoinsetProvider::new("coinset", 10, list_source(flood))),
890            None,
891            "coinset.org",
892        );
893
894        assert!(
895            matches!(
896                registry.any().coin_records_by_puzzle_hash(ph, false),
897                Err(ChainSourceError::TooManyRecords { count, limit })
898                    if count == MAX_QUORUM_RECORDS + 1 && limit == MAX_QUORUM_RECORDS
899            ),
900            "an over-cap discovery answer must be rejected as TooManyRecords"
901        );
902    }
903
904    /// #1351: a within-cap discovery answer still passes through unchanged (the bound only rejects
905    /// implausibly large responses).
906    #[test]
907    fn discovery_passes_within_cap_response() {
908        let ph = Bytes32::new([0x22; 32]);
909        let a = record_for(coin_id(0x0A));
910        let b = record_for(coin_id(0x0B));
911
912        let registry = ProviderRegistry::new().register(
913            Box::new(CoinsetProvider::new(
914                "coinset",
915                10,
916                list_source(vec![a.clone(), b.clone()]),
917            )),
918            None,
919            "coinset.org",
920        );
921
922        let records = registry
923            .any()
924            .coin_records_by_puzzle_hash(ph, false)
925            .expect("a within-cap discovery answer must pass through");
926        assert_eq!(records.len(), 2);
927        assert!(records.contains(&a) && records.contains(&b));
928    }
929
930    #[test]
931    fn every_read_method_flows_through_both_views() {
932        let ph = Bytes32::new([0x22; 32]);
933        let parent = Coin::new(Bytes32::new([0x01; 32]), ph, 1);
934        let parent_id = parent.coin_id();
935        let child = Coin::new(parent_id, ph, 1);
936        let launcher = Bytes32::new([0x77; 32]);
937
938        let source = MockChainSource::new()
939            .with_coin(parent_id, record_for(parent_id))
940            .with_coin(child.coin_id(), {
941                let mut r = record_for(child.coin_id());
942                r.coin = child;
943                r
944            })
945            .with_spend(
946                parent_id,
947                chia_protocol::CoinSpend::new(
948                    parent,
949                    chia_protocol::Program::from(vec![1]),
950                    chia_protocol::Program::from(vec![0x80]),
951                ),
952            )
953            .with_lineage(launcher, SingletonLineage::single(launcher))
954            .with_timestamp(100, 1_700_000_000)
955            .with_peak(555);
956
957        let registry = ProviderRegistry::new().register(
958            Box::new(LocalNodeProvider::new("local", 0, source)),
959            None, // Trusted
960            "local-node",
961        );
962
963        // Custody view — every method answers via the trusted source.
964        let custody = registry.trusted();
965        assert!(!custody
966            .coin_records_by_puzzle_hash(ph, true)
967            .unwrap()
968            .is_empty());
969        assert!(!custody
970            .coin_records_by_parent(parent_id)
971            .unwrap()
972            .is_empty());
973        assert!(custody.coin_spend(parent_id).unwrap().is_some());
974        assert_eq!(custody.peak_height().unwrap(), Some(555));
975        assert_eq!(custody.block_timestamp(100).unwrap(), Some(1_700_000_000));
976        assert_eq!(
977            custody.resolve_singleton_lineage(launcher).unwrap(),
978            Some(SingletonLineage::single(launcher))
979        );
980
981        // Discovery view — same reads, single-provider, no trust guarantee.
982        let discovery = registry.any();
983        assert!(discovery.coin_record(parent_id).unwrap().is_some());
984        assert!(!discovery
985            .coin_records_by_puzzle_hash(ph, false)
986            .unwrap()
987            .is_empty());
988        assert!(!discovery
989            .coin_records_by_parent(parent_id)
990            .unwrap()
991            .is_empty());
992        assert!(discovery.coin_spend(parent_id).unwrap().is_some());
993        assert_eq!(discovery.peak_height().unwrap(), Some(555));
994        assert_eq!(discovery.block_timestamp(100).unwrap(), Some(1_700_000_000));
995        assert!(discovery
996            .resolve_singleton_lineage(launcher)
997            .unwrap()
998            .is_some());
999    }
1000
1001    #[test]
1002    fn discovery_falls_through_failing_providers_to_a_responder() {
1003        let id = coin_id(0x0B);
1004        let registry = ProviderRegistry::new()
1005            .register(
1006                Box::new(CoinsetProvider::new(
1007                    "down",
1008                    0,
1009                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
1010                )),
1011                None,
1012                "down",
1013            )
1014            .register(
1015                Box::new(CoinsetProvider::new("up", 10, mock_with(id))),
1016                None,
1017                "up",
1018            );
1019        assert_eq!(
1020            registry.any().coin_record(id).unwrap(),
1021            Some(record_for(id))
1022        );
1023    }
1024
1025    #[test]
1026    fn empty_registry_fails_closed_everywhere() {
1027        let registry = ProviderRegistry::new();
1028        let id = coin_id(0x0A);
1029        assert_eq!(
1030            registry.trusted().coin_record(id),
1031            Err(ChainSourceError::NoProvider)
1032        );
1033        assert_eq!(
1034            registry.any().coin_record(id),
1035            Err(ChainSourceError::NoProvider)
1036        );
1037    }
1038}