use chia_protocol::{Bytes32, CoinSpend};
use dig_chainsource_interface::{
ChainSource, ChainSourceError, ChainSourceProvider, CoinRecord, ProviderKind, SingletonLineage,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustLevel {
Trusted,
Untrusted,
}
impl TrustLevel {
pub fn default_for(kind: ProviderKind) -> Self {
match kind {
ProviderKind::LocalNode => Self::Trusted,
ProviderKind::PublicOracle | ProviderKind::DigPeers | ProviderKind::Custom => {
Self::Untrusted
}
}
}
}
const PUBLIC_QUORUM_THRESHOLD: usize = 2;
const MAX_QUORUM_RECORDS: usize = 100_000;
type DynProvider = dyn ChainSourceProvider<Error = ChainSourceError>;
struct Registration {
provider: Box<DynProvider>,
trust: TrustLevel,
independence_group: String,
}
#[derive(Default)]
pub struct ProviderRegistry {
providers: Vec<Registration>,
allow_public_quorum_custody: bool,
}
impl ProviderRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn allow_public_quorum_custody(mut self, allow: bool) -> Self {
self.allow_public_quorum_custody = allow;
if allow {
log::warn!(
"chia-query registry: pure-public-quorum custody ENABLED — custody reads may be \
satisfied by {PUBLIC_QUORUM_THRESHOLD} independent public sources with NO \
operator-trusted source. Reduced assurance vs a trusted local node."
);
}
self
}
pub fn register(
mut self,
provider: Box<DynProvider>,
trust_override: Option<TrustLevel>,
independence_group: impl Into<String>,
) -> Self {
let trust = trust_override
.unwrap_or_else(|| TrustLevel::default_for(provider.provider_info().kind));
self.providers.push(Registration {
provider,
trust,
independence_group: independence_group.into(),
});
self
}
pub fn trusted(&self) -> TrustedView<'_> {
TrustedView { registry: self }
}
pub fn any(&self) -> DiscoveryView<'_> {
DiscoveryView { registry: self }
}
fn by_priority(&self) -> Vec<&Registration> {
let mut ordered: Vec<&Registration> = self.providers.iter().collect();
ordered.sort_by_key(|reg| reg.provider.provider_info().priority);
ordered
}
}
pub struct TrustedView<'a> {
registry: &'a ProviderRegistry,
}
impl TrustedView<'_> {
fn custody_read<T, Q>(&self, query: Q) -> Result<T, ChainSourceError>
where
T: QuorumComparable + Clone,
Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
{
let trusted: Vec<&Registration> = self
.registry
.by_priority()
.into_iter()
.filter(|reg| reg.trust == TrustLevel::Trusted)
.collect();
if !trusted.is_empty() {
let mut last_error = ChainSourceError::NoProvider;
for reg in trusted {
match query(&*reg.provider) {
Ok(value) => return Ok(value),
Err(error) => last_error = error,
}
}
return Err(last_error);
}
if !self.registry.allow_public_quorum_custody {
return Err(ChainSourceError::NoProvider);
}
quorum_read(&self.registry.providers, PUBLIC_QUORUM_THRESHOLD, query)
}
}
pub struct DiscoveryView<'a> {
registry: &'a ProviderRegistry,
}
impl DiscoveryView<'_> {
fn discovery_read<T, Q>(&self, query: Q) -> Result<T, ChainSourceError>
where
Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
{
let mut last_error = ChainSourceError::NoProvider;
for reg in self.registry.by_priority() {
match query(&*reg.provider) {
Ok(value) => return Ok(value),
Err(error) => last_error = error,
}
}
Err(last_error)
}
}
trait QuorumComparable {
fn quorum_eq(&self, other: &Self) -> bool;
fn validate_bound(&self) -> Result<(), ChainSourceError> {
Ok(())
}
}
macro_rules! quorum_eq_via_partial_eq {
($($t:ty),+ $(,)?) => {
$(impl QuorumComparable for $t {
fn quorum_eq(&self, other: &Self) -> bool {
self == other
}
})+
};
}
quorum_eq_via_partial_eq!(
Option<CoinRecord>,
Option<CoinSpend>,
Option<SingletonLineage>,
Option<u32>,
Option<u64>,
);
impl QuorumComparable for Vec<CoinRecord> {
fn quorum_eq(&self, other: &Self) -> bool {
canonical_order(self) == canonical_order(other)
}
fn validate_bound(&self) -> Result<(), ChainSourceError> {
if self.len() > MAX_QUORUM_RECORDS {
return Err(ChainSourceError::Malformed(format!(
"untrusted source returned {} coin records, exceeding the {MAX_QUORUM_RECORDS} cap",
self.len()
)));
}
Ok(())
}
}
fn canonical_order(records: &[CoinRecord]) -> Vec<(Bytes32, &CoinRecord)> {
let mut keyed: Vec<(Bytes32, &CoinRecord)> =
records.iter().map(|r| (r.coin.coin_id(), r)).collect();
keyed.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()));
keyed
}
fn quorum_read<T, Q>(
providers: &[Registration],
threshold: usize,
query: Q,
) -> Result<T, ChainSourceError>
where
T: QuorumComparable + Clone,
Q: Fn(&DynProvider) -> Result<T, ChainSourceError>,
{
let mut per_group: Vec<(&str, T)> = Vec::new();
for reg in providers {
let group = reg.independence_group.as_str();
if per_group.iter().any(|(existing, _)| *existing == group) {
continue; }
if let Ok(answer) = query(&*reg.provider) {
if answer.validate_bound().is_err() {
continue;
}
per_group.push((group, answer));
}
}
for (_, candidate) in &per_group {
let agreeing = per_group
.iter()
.filter(|(_, a)| a.quorum_eq(candidate))
.count();
if agreeing >= threshold {
return Ok(candidate.clone());
}
}
Err(ChainSourceError::NoProvider)
}
impl ChainSource for TrustedView<'_> {
type Error = ChainSourceError;
fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
self.custody_read(move |p| p.coin_record(coin_id))
}
fn coin_records_by_puzzle_hash(
&self,
puzzle_hash: Bytes32,
include_spent: bool,
) -> Result<Vec<CoinRecord>, Self::Error> {
self.custody_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
}
fn coin_records_by_parent(
&self,
parent_coin_id: Bytes32,
) -> Result<Vec<CoinRecord>, Self::Error> {
self.custody_read(move |p| p.coin_records_by_parent(parent_coin_id))
}
fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
self.custody_read(move |p| p.coin_spend(coin_id))
}
fn resolve_singleton_lineage(
&self,
launcher_id: Bytes32,
) -> Result<Option<SingletonLineage>, Self::Error> {
self.custody_read(move |p| p.resolve_singleton_lineage(launcher_id))
}
fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
self.custody_read(|p| p.peak_height())
}
fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
self.custody_read(move |p| p.block_timestamp(height))
}
}
impl ChainSource for DiscoveryView<'_> {
type Error = ChainSourceError;
fn coin_record(&self, coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
self.discovery_read(move |p| p.coin_record(coin_id))
}
fn coin_records_by_puzzle_hash(
&self,
puzzle_hash: Bytes32,
include_spent: bool,
) -> Result<Vec<CoinRecord>, Self::Error> {
self.discovery_read(move |p| p.coin_records_by_puzzle_hash(puzzle_hash, include_spent))
}
fn coin_records_by_parent(
&self,
parent_coin_id: Bytes32,
) -> Result<Vec<CoinRecord>, Self::Error> {
self.discovery_read(move |p| p.coin_records_by_parent(parent_coin_id))
}
fn coin_spend(&self, coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
self.discovery_read(move |p| p.coin_spend(coin_id))
}
fn resolve_singleton_lineage(
&self,
launcher_id: Bytes32,
) -> Result<Option<SingletonLineage>, Self::Error> {
self.discovery_read(move |p| p.resolve_singleton_lineage(launcher_id))
}
fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
self.discovery_read(|p| p.peak_height())
}
fn block_timestamp(&self, height: u32) -> Result<Option<u64>, Self::Error> {
self.discovery_read(move |p| p.block_timestamp(height))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chia_protocol::Coin;
use dig_chainsource_interface::MockChainSource;
use crate::provider_registry::providers::{CoinsetProvider, CustomProvider, LocalNodeProvider};
fn coin_id(byte: u8) -> Bytes32 {
Coin::new(Bytes32::new([byte; 32]), Bytes32::new([byte; 32]), 1).coin_id()
}
fn record_for(id: Bytes32) -> CoinRecord {
CoinRecord {
coin: Coin::new(id, Bytes32::new([0x22; 32]), 1),
confirmed_height: Some(100),
spent_height: None,
timestamp: Some(1_700_000_000),
coinbase: false,
}
}
fn mock_with(id: Bytes32) -> MockChainSource {
MockChainSource::new().with_coin(id, record_for(id))
}
#[test]
fn pure_public_quorum_without_optin_fails_closed_for_custody() {
let id = coin_id(0x01);
let registry = ProviderRegistry::new()
.register(
Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new("mirror-b", 20, mock_with(id))),
None,
"mirror.example",
);
let result = registry.trusted().coin_record(id);
assert_eq!(
result,
Err(ChainSourceError::NoProvider),
"pure-public custody must fail closed without allow_public_quorum_custody"
);
}
#[test]
fn operator_trusted_local_node_satisfies_custody() {
let id = coin_id(0x02);
let registry = ProviderRegistry::new().register(
Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
None, "local-node",
);
let record = registry.trusted().coin_record(id).unwrap();
assert_eq!(record, Some(record_for(id)));
}
#[test]
fn local_node_defaults_to_trusted() {
assert_eq!(
TrustLevel::default_for(ProviderKind::LocalNode),
TrustLevel::Trusted
);
assert_eq!(
TrustLevel::default_for(ProviderKind::PublicOracle),
TrustLevel::Untrusted
);
}
#[test]
fn quorum_including_trusted_member_satisfies_custody() {
let id = coin_id(0x03);
let registry = ProviderRegistry::new()
.register(
Box::new(LocalNodeProvider::new("local", 0, mock_with(id))),
None, "local-node",
)
.register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None, "coinset.org",
);
let record = registry.trusted().coin_record(id).unwrap();
assert_eq!(record, Some(record_for(id)));
}
#[test]
fn trusted_source_error_fails_closed_not_public_fallback() {
let id = coin_id(0x04);
let registry = ProviderRegistry::new()
.register(
Box::new(LocalNodeProvider::new(
"local",
0,
MockChainSource::new().fail_with(ChainSourceError::Timeout),
)),
None,
"local-node",
)
.register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None,
"coinset.org",
);
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::Timeout)
);
}
#[test]
fn optin_two_independent_groups_agree_satisfies_custody() {
let id = coin_id(0x05);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new("mirror", 20, mock_with(id))),
None,
"mirror.example",
);
assert_eq!(
registry.trusted().coin_record(id).unwrap(),
Some(record_for(id))
);
}
#[test]
fn optin_single_group_fails_closed() {
let id = coin_id(0x06);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None,
"coinset.org",
);
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::NoProvider)
);
}
#[test]
fn optin_two_providers_same_group_fails_closed() {
let id = coin_id(0x07);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new("coinset-a", 10, mock_with(id))),
None,
"coinset.org",
)
.register(
Box::new(CoinsetProvider::new("coinset-b", 20, mock_with(id))),
None,
"coinset.org", );
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::NoProvider)
);
}
#[test]
fn optin_two_groups_disagree_fails_closed() {
let id = coin_id(0x08);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new("empty", 20, MockChainSource::new())),
None,
"mirror.example",
);
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::NoProvider)
);
}
struct FixedListSource {
records: Vec<CoinRecord>,
}
impl ChainSource for FixedListSource {
type Error = ChainSourceError;
fn coin_record(&self, _coin_id: Bytes32) -> Result<Option<CoinRecord>, Self::Error> {
Ok(None)
}
fn coin_records_by_puzzle_hash(
&self,
_puzzle_hash: Bytes32,
_include_spent: bool,
) -> Result<Vec<CoinRecord>, Self::Error> {
Ok(self.records.clone())
}
fn coin_records_by_parent(
&self,
_parent_coin_id: Bytes32,
) -> Result<Vec<CoinRecord>, Self::Error> {
Ok(self.records.clone())
}
fn coin_spend(&self, _coin_id: Bytes32) -> Result<Option<CoinSpend>, Self::Error> {
Ok(None)
}
fn resolve_singleton_lineage(
&self,
_launcher_id: Bytes32,
) -> Result<Option<SingletonLineage>, Self::Error> {
Ok(None)
}
fn peak_height(&self) -> Result<Option<u32>, Self::Error> {
Ok(None)
}
fn block_timestamp(&self, _height: u32) -> Result<Option<u64>, Self::Error> {
Ok(None)
}
}
fn list_source(records: Vec<CoinRecord>) -> FixedListSource {
FixedListSource { records }
}
#[test]
fn quorum_agrees_on_same_record_set_in_different_order() {
let ph = Bytes32::new([0x22; 32]);
let a = record_for(coin_id(0x0A));
let b = record_for(coin_id(0x0B));
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new(
"coinset",
10,
list_source(vec![a.clone(), b.clone()]),
)),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new(
"mirror",
20,
list_source(vec![b.clone(), a.clone()]),
)),
None,
"mirror.example",
);
let records = registry
.trusted()
.coin_records_by_puzzle_hash(ph, false)
.expect("honest sources agreeing on a set must satisfy the quorum");
assert_eq!(records.len(), 2);
assert!(records.contains(&a) && records.contains(&b));
}
#[test]
fn quorum_still_fails_closed_on_genuinely_different_record_sets() {
let ph = Bytes32::new([0x22; 32]);
let a = record_for(coin_id(0x0A));
let b = record_for(coin_id(0x0B));
let c = record_for(coin_id(0x0C));
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new(
"coinset",
10,
list_source(vec![a.clone(), b]),
)),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new("mirror", 20, list_source(vec![c, a]))),
None,
"mirror.example",
);
assert_eq!(
registry.trusted().coin_records_by_puzzle_hash(ph, false),
Err(ChainSourceError::NoProvider),
"genuinely different record sets must fail closed"
);
}
fn oversized_flood(ph: Bytes32) -> Vec<CoinRecord> {
let flood: Vec<CoinRecord> = (0..=MAX_QUORUM_RECORDS as u32)
.map(|i| {
let coin = Coin::new(Bytes32::new([0x01; 32]), ph, u64::from(i) + 1);
let mut r = record_for(coin.coin_id());
r.coin = coin;
r
})
.collect();
assert!(flood.len() > MAX_QUORUM_RECORDS);
flood
}
#[test]
fn quorum_fails_closed_when_all_sources_exceed_the_record_cap() {
let ph = Bytes32::new([0x22; 32]);
let flood = oversized_flood(ph);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new(
"coinset",
10,
list_source(flood.clone()),
)),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new("mirror", 20, list_source(flood))),
None,
"mirror.example",
);
assert_eq!(
registry.trusted().coin_records_by_puzzle_hash(ph, false),
Err(ChainSourceError::NoProvider),
"when every source floods, custody must fail closed"
);
}
#[test]
fn oversized_quorum_member_is_skipped_not_fatal() {
let ph = Bytes32::new([0x22; 32]);
let a = record_for(coin_id(0x0A));
let b = record_for(coin_id(0x0B));
let flood = oversized_flood(ph);
let registry = ProviderRegistry::new()
.allow_public_quorum_custody(true)
.register(
Box::new(CoinsetProvider::new(
"coinset",
10,
list_source(vec![a.clone(), b.clone()]),
)),
None,
"coinset.org",
)
.register(
Box::new(CustomProvider::new(
"mirror",
20,
list_source(vec![b.clone(), a.clone()]),
)),
None,
"mirror.example",
)
.register(
Box::new(CustomProvider::new("flooder", 30, list_source(flood))),
None,
"flooder.example",
);
let records = registry
.trusted()
.coin_records_by_puzzle_hash(ph, false)
.expect("an honest 2-group agreement must still satisfy the quorum");
assert_eq!(records.len(), 2, "the flood must not enter the tally");
assert!(records.contains(&a) && records.contains(&b));
}
#[test]
fn discovery_view_returns_single_provider_answer() {
let id = coin_id(0x09);
let registry = ProviderRegistry::new().register(
Box::new(CoinsetProvider::new("coinset", 10, mock_with(id))),
None,
"coinset.org",
);
assert_eq!(
registry.any().coin_record(id).unwrap(),
Some(record_for(id))
);
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::NoProvider)
);
}
#[test]
fn every_read_method_flows_through_both_views() {
let ph = Bytes32::new([0x22; 32]);
let parent = Coin::new(Bytes32::new([0x01; 32]), ph, 1);
let parent_id = parent.coin_id();
let child = Coin::new(parent_id, ph, 1);
let launcher = Bytes32::new([0x77; 32]);
let source = MockChainSource::new()
.with_coin(parent_id, record_for(parent_id))
.with_coin(child.coin_id(), {
let mut r = record_for(child.coin_id());
r.coin = child;
r
})
.with_spend(
parent_id,
chia_protocol::CoinSpend::new(
parent,
chia_protocol::Program::from(vec![1]),
chia_protocol::Program::from(vec![0x80]),
),
)
.with_lineage(launcher, SingletonLineage::single(launcher))
.with_timestamp(100, 1_700_000_000)
.with_peak(555);
let registry = ProviderRegistry::new().register(
Box::new(LocalNodeProvider::new("local", 0, source)),
None, "local-node",
);
let custody = registry.trusted();
assert!(!custody
.coin_records_by_puzzle_hash(ph, true)
.unwrap()
.is_empty());
assert!(!custody
.coin_records_by_parent(parent_id)
.unwrap()
.is_empty());
assert!(custody.coin_spend(parent_id).unwrap().is_some());
assert_eq!(custody.peak_height().unwrap(), Some(555));
assert_eq!(custody.block_timestamp(100).unwrap(), Some(1_700_000_000));
assert_eq!(
custody.resolve_singleton_lineage(launcher).unwrap(),
Some(SingletonLineage::single(launcher))
);
let discovery = registry.any();
assert!(discovery.coin_record(parent_id).unwrap().is_some());
assert!(!discovery
.coin_records_by_puzzle_hash(ph, false)
.unwrap()
.is_empty());
assert!(!discovery
.coin_records_by_parent(parent_id)
.unwrap()
.is_empty());
assert!(discovery.coin_spend(parent_id).unwrap().is_some());
assert_eq!(discovery.peak_height().unwrap(), Some(555));
assert_eq!(discovery.block_timestamp(100).unwrap(), Some(1_700_000_000));
assert!(discovery
.resolve_singleton_lineage(launcher)
.unwrap()
.is_some());
}
#[test]
fn discovery_falls_through_failing_providers_to_a_responder() {
let id = coin_id(0x0B);
let registry = ProviderRegistry::new()
.register(
Box::new(CoinsetProvider::new(
"down",
0,
MockChainSource::new().fail_with(ChainSourceError::Timeout),
)),
None,
"down",
)
.register(
Box::new(CoinsetProvider::new("up", 10, mock_with(id))),
None,
"up",
);
assert_eq!(
registry.any().coin_record(id).unwrap(),
Some(record_for(id))
);
}
#[test]
fn empty_registry_fails_closed_everywhere() {
let registry = ProviderRegistry::new();
let id = coin_id(0x0A);
assert_eq!(
registry.trusted().coin_record(id),
Err(ChainSourceError::NoProvider)
);
assert_eq!(
registry.any().coin_record(id),
Err(ChainSourceError::NoProvider)
);
}
}