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: PartialEq + 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/// Requires `threshold` DISTINCT independence groups to return the SAME answer for `query`.
193///
194/// Each group contributes at most one answer (its first provider that responds); the count is over
195/// distinct groups, so two providers in the same group can never satisfy a `threshold >= 2` on
196/// their own. Insufficient agreement — disagreement, too few groups, or all errors — fails closed
197/// (SPEC §5).
198fn quorum_read<T, Q>(
199    providers: &[Registration],
200    threshold: usize,
201    query: Q,
202) -> Result<T, ChainSourceError>
203where
204    T: PartialEq + Clone,
205    Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
206{
207    // One representative answer per independence group (the first provider in the group to answer).
208    let mut per_group: Vec<(&str, T)> = Vec::new();
209    for reg in providers {
210        let group = reg.independence_group.as_str();
211        if per_group.iter().any(|(existing, _)| *existing == group) {
212            continue; // this group already contributed a representative answer
213        }
214        if let Ok(answer) = query(&*reg.provider) {
215            per_group.push((group, answer));
216        }
217    }
218
219    // An answer qualifies when `threshold` distinct groups agree on it.
220    for (_, candidate) in &per_group {
221        let agreeing = per_group.iter().filter(|(_, a)| a == candidate).count();
222        if agreeing >= threshold {
223            return Ok(candidate.clone());
224        }
225    }
226    Err(ChainSourceError::NoProvider)
227}
228
229// The two views present the full [`ChainSource`] surface; each method dispatches through the view's
230// trust rule via a closure capturing the query's arguments.
231
232impl ChainSource for TrustedView<'_> {
233    type Error = ChainSourceError;
234
235    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
236        self.custody_read(move |p| p.coin_record(coin_id))
237    }
238
239    fn coin_records_by_puzzle_hash(
240        &self,
241        puzzle_hash: Bytes32,
242        include_spent: bool,
243    ) -> Result<Vec<CoinRecord>, Self::Error> {
244        self.custody_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
245    }
246
247    fn coin_records_by_parent(
248        &self,
249        parent_coin_id: Bytes32,
250    ) -> Result<Vec<CoinRecord>, Self::Error> {
251        self.custody_read(move |p| p.coin_records_by_parent(parent_coin_id))
252    }
253
254    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
255        self.custody_read(move |p| p.coin_spend(coin_id))
256    }
257
258    fn resolve_singleton_lineage(
259        &self,
260        launcher_id: Bytes32,
261    ) -> Result<Option<SingletonLineage>, Self::Error> {
262        // Cross-source agreement on a lineage requires the FULL lineage to match; consumers still
263        // apply the authority MEMBERSHIP test (`SingletonLineage::contains`) to the result — never
264        // tip/puzzle-hash equality (SPEC §5).
265        self.custody_read(move |p| p.resolve_singleton_lineage(launcher_id))
266    }
267
268    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
269        self.custody_read(|p| p.peak_height())
270    }
271
272    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
273        self.custody_read(move |p| p.block_timestamp(height))
274    }
275}
276
277impl ChainSource for DiscoveryView<'_> {
278    type Error = ChainSourceError;
279
280    fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
281        self.discovery_read(move |p| p.coin_record(coin_id))
282    }
283
284    fn coin_records_by_puzzle_hash(
285        &self,
286        puzzle_hash: Bytes32,
287        include_spent: bool,
288    ) -> Result<Vec<CoinRecord>, Self::Error> {
289        self.discovery_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
290    }
291
292    fn coin_records_by_parent(
293        &self,
294        parent_coin_id: Bytes32,
295    ) -> Result<Vec<CoinRecord>, Self::Error> {
296        self.discovery_read(move |p| p.coin_records_by_parent(parent_coin_id))
297    }
298
299    fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
300        self.discovery_read(move |p| p.coin_spend(coin_id))
301    }
302
303    fn resolve_singleton_lineage(
304        &self,
305        launcher_id: Bytes32,
306    ) -> Result<Option<SingletonLineage>, Self::Error> {
307        self.discovery_read(move |p| p.resolve_singleton_lineage(launcher_id))
308    }
309
310    fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
311        self.discovery_read(|p| p.peak_height())
312    }
313
314    fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
315        self.discovery_read(move |p| p.block_timestamp(height))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use chia_protocol::Coin;
323    use dig_chainsource_interface::MockChainSource;
324
325    use crate::provider_registry::providers::{CoinsetProvider, CustomProvider, LocalNodeProvider};
326
327    fn coin_id(byte: u8) -> Bytes32 {
328        Coin::new(Bytes32::new([byte; 32]), Bytes32::new([byte; 32]), 1).coin_id()
329    }
330
331    fn record_for(id: Bytes32) -> CoinRecord {
332        CoinRecord {
333            coin: Coin::new(id, Bytes32::new([0x22; 32]), 1),
334            confirmed_height: Some(100),
335            spent_height: None,
336            timestamp: Some(1_700_000_000),
337            coinbase: false,
338        }
339    }
340
341    fn mock_with(id: Bytes32) -> MockChainSource {
342        MockChainSource::new().with_coin(id, record_for(id))
343    }
344
345    // ---- Test #2 (LOAD-BEARING): pure-public quorum WITHOUT opt-in fails closed ----
346
347    #[test]
348    fn pure_public_quorum_without_optin_fails_closed_for_custody() {
349        let id = coin_id(0x01);
350        // Two INDEPENDENT public sources that both KNOW the coin and AGREE — yet, with no
351        // operator-trusted source and no opt-in, custody MUST NOT be satisfied.
352        let registry = ProviderRegistry::new()
353            .register(
354                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
355                None,
356                "coinset.org",
357            )
358            .register(
359                Box::new(CustomProvider::new("mirror-b", 20, mock_with(id))),
360                None,
361                "mirror.example",
362            );
363
364        let result = registry.trusted().coin_record(id);
365        assert_eq!(
366            result,
367            Err(ChainSourceError::NoProvider),
368            "pure-public custody must fail closed without allow_public_quorum_custody"
369        );
370    }
371
372    // ---- Test #3: an operator-Trusted LocalNode satisfies custody ----
373
374    #[test]
375    fn operator_trusted_local_node_satisfies_custody() {
376        let id = coin_id(0x02);
377        let registry = ProviderRegistry::new().register(
378            Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
379            None, // LocalNode defaults to Trusted
380            "local-node",
381        );
382
383        let record = registry.trusted().coin_record(id).unwrap();
384        assert_eq!(record, Some(record_for(id)));
385    }
386
387    #[test]
388    fn local_node_defaults_to_trusted() {
389        assert_eq!(
390            TrustLevel::default_for(ProviderKind::LocalNode),
391            TrustLevel::Trusted
392        );
393        assert_eq!(
394            TrustLevel::default_for(ProviderKind::PublicOracle),
395            TrustLevel::Untrusted
396        );
397    }
398
399    // ---- Test #4: a quorum INCLUDING a trusted member satisfies custody ----
400
401    #[test]
402    fn quorum_including_trusted_member_satisfies_custody() {
403        let id = coin_id(0x03);
404        let registry = ProviderRegistry::new()
405            .register(
406                Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
407                None, // Trusted
408                "local-node",
409            )
410            .register(
411                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
412                None, // Untrusted public
413                "coinset.org",
414            );
415
416        // The trusted member answers; custody is satisfied.
417        let record = registry.trusted().coin_record(id).unwrap();
418        assert_eq!(record, Some(record_for(id)));
419    }
420
421    #[test]
422    fn trusted_source_error_fails_closed_not_public_fallback() {
423        let id = coin_id(0x04);
424        let registry = ProviderRegistry::new()
425            .register(
426                Box::new(LocalNodeProvider::new(
427                    "local",
428                    0,
429                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
430                )),
431                None,
432                "local-node",
433            )
434            .register(
435                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
436                None,
437                "coinset.org",
438            );
439
440        // The trusted node errored; custody fails closed rather than reading the public source.
441        assert_eq!(
442            registry.trusted().coin_record(id),
443            Err(ChainSourceError::Timeout)
444        );
445    }
446
447    // ---- Test #5: opt-in pure-public quorum needs T=2 INDEPENDENT groups ----
448
449    #[test]
450    fn optin_two_independent_groups_agree_satisfies_custody() {
451        let id = coin_id(0x05);
452        let registry = ProviderRegistry::new()
453            .allow_public_quorum_custody(true)
454            .register(
455                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
456                None,
457                "coinset.org",
458            )
459            .register(
460                Box::new(CustomProvider::new("mirror", 20, mock_with(id))),
461                None,
462                "mirror.example",
463            );
464
465        assert_eq!(
466            registry.trusted().coin_record(id).unwrap(),
467            Some(record_for(id))
468        );
469    }
470
471    #[test]
472    fn optin_single_group_fails_closed() {
473        let id = coin_id(0x06);
474        // Only ONE independent group qualifies -> below threshold -> fail closed.
475        let registry = ProviderRegistry::new()
476            .allow_public_quorum_custody(true)
477            .register(
478                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
479                None,
480                "coinset.org",
481            );
482
483        assert_eq!(
484            registry.trusted().coin_record(id),
485            Err(ChainSourceError::NoProvider)
486        );
487    }
488
489    #[test]
490    fn optin_two_providers_same_group_fails_closed() {
491        let id = coin_id(0x07);
492        // Two providers agreeing but in the SAME independence group count as ONE -> below T=2.
493        let registry = ProviderRegistry::new()
494            .allow_public_quorum_custody(true)
495            .register(
496                Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
497                None,
498                "coinset.org",
499            )
500            .register(
501                Box::new(CoinsetProvider::new("coinset-b", 20, mock_with(id))),
502                None,
503                "coinset.org", // SAME group
504            );
505
506        assert_eq!(
507            registry.trusted().coin_record(id),
508            Err(ChainSourceError::NoProvider)
509        );
510    }
511
512    #[test]
513    fn optin_two_groups_disagree_fails_closed() {
514        let id = coin_id(0x08);
515        // Two independent groups that DISAGREE (one knows the coin, one does not) -> no quorum.
516        let registry = ProviderRegistry::new()
517            .allow_public_quorum_custody(true)
518            .register(
519                Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
520                None,
521                "coinset.org",
522            )
523            .register(
524                Box::new(CustomProvider::new("empty", 20, MockChainSource::new())),
525                None,
526                "mirror.example",
527            );
528
529        // coinset says Some(record), mirror says Ok(None) — disagreement -> fail closed.
530        assert_eq!(
531            registry.trusted().coin_record(id),
532            Err(ChainSourceError::NoProvider)
533        );
534    }
535
536    // ---- Test #7: discovery view returns a single-provider answer, inert for custody ----
537
538    #[test]
539    fn discovery_view_returns_single_provider_answer() {
540        let id = coin_id(0x09);
541        let registry = ProviderRegistry::new().register(
542            Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
543            None,
544            "coinset.org",
545        );
546
547        // Discovery answers from the single public provider...
548        assert_eq!(
549            registry.any().coin_record(id).unwrap(),
550            Some(record_for(id))
551        );
552        // ...but the SAME registry fails closed for custody (no trusted source, no opt-in).
553        assert_eq!(
554            registry.trusted().coin_record(id),
555            Err(ChainSourceError::NoProvider)
556        );
557    }
558
559    #[test]
560    fn every_read_method_flows_through_both_views() {
561        let ph = Bytes32::new([0x22; 32]);
562        let parent = Coin::new(Bytes32::new([0x01; 32]), ph, 1);
563        let parent_id = parent.coin_id();
564        let child = Coin::new(parent_id, ph, 1);
565        let launcher = Bytes32::new([0x77; 32]);
566
567        let source = MockChainSource::new()
568            .with_coin(parent_id, record_for(parent_id))
569            .with_coin(child.coin_id(), {
570                let mut r = record_for(child.coin_id());
571                r.coin = child;
572                r
573            })
574            .with_spend(
575                parent_id,
576                chia_protocol::CoinSpend::new(
577                    parent,
578                    chia_protocol::Program::from(vec![1]),
579                    chia_protocol::Program::from(vec![0x80]),
580                ),
581            )
582            .with_lineage(launcher, SingletonLineage::single(launcher))
583            .with_timestamp(100, 1_700_000_000)
584            .with_peak(555);
585
586        let registry = ProviderRegistry::new().register(
587            Box::new(LocalNodeProvider::new("local", 0, source)),
588            None, // Trusted
589            "local-node",
590        );
591
592        // Custody view — every method answers via the trusted source.
593        let custody = registry.trusted();
594        assert!(!custody
595            .coin_records_by_puzzle_hash(ph, true)
596            .unwrap()
597            .is_empty());
598        assert!(!custody
599            .coin_records_by_parent(parent_id)
600            .unwrap()
601            .is_empty());
602        assert!(custody.coin_spend(parent_id).unwrap().is_some());
603        assert_eq!(custody.peak_height().unwrap(), Some(555));
604        assert_eq!(custody.block_timestamp(100).unwrap(), Some(1_700_000_000));
605        assert_eq!(
606            custody.resolve_singleton_lineage(launcher).unwrap(),
607            Some(SingletonLineage::single(launcher))
608        );
609
610        // Discovery view — same reads, single-provider, no trust guarantee.
611        let discovery = registry.any();
612        assert!(discovery.coin_record(parent_id).unwrap().is_some());
613        assert!(!discovery
614            .coin_records_by_puzzle_hash(ph, false)
615            .unwrap()
616            .is_empty());
617        assert!(!discovery
618            .coin_records_by_parent(parent_id)
619            .unwrap()
620            .is_empty());
621        assert!(discovery.coin_spend(parent_id).unwrap().is_some());
622        assert_eq!(discovery.peak_height().unwrap(), Some(555));
623        assert_eq!(discovery.block_timestamp(100).unwrap(), Some(1_700_000_000));
624        assert!(discovery
625            .resolve_singleton_lineage(launcher)
626            .unwrap()
627            .is_some());
628    }
629
630    #[test]
631    fn discovery_falls_through_failing_providers_to_a_responder() {
632        let id = coin_id(0x0B);
633        let registry = ProviderRegistry::new()
634            .register(
635                Box::new(CoinsetProvider::new(
636                    "down",
637                    0,
638                    MockChainSource::new().fail_with(ChainSourceError::Timeout),
639                )),
640                None,
641                "down",
642            )
643            .register(
644                Box::new(CoinsetProvider::new("up", 10, mock_with(id))),
645                None,
646                "up",
647            );
648        assert_eq!(
649            registry.any().coin_record(id).unwrap(),
650            Some(record_for(id))
651        );
652    }
653
654    #[test]
655    fn empty_registry_fails_closed_everywhere() {
656        let registry = ProviderRegistry::new();
657        let id = coin_id(0x0A);
658        assert_eq!(
659            registry.trusted().coin_record(id),
660            Err(ChainSourceError::NoProvider)
661        );
662        assert_eq!(
663            registry.any().coin_record(id),
664            Err(ChainSourceError::NoProvider)
665        );
666    }
667}