Skip to content
Open
54 changes: 54 additions & 0 deletions docs/PRICE_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,14 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (§6.6)

Phases 3 and 4 both depend on Phase 2 and can land in either order.

> **Out-of-band addition — Nostr *subscribe* (issue #697, §11.7).** The
> five phases above only ever covered *publishing* rates to Nostr (Phase 5).
> A separate, independently-landing addition — the `nostr` provider — makes
> `mostrod` able to *consume* another Mostro node's published rates instead
> of an HTTP API, for operators in regions where the price APIs are
> network-blocked. It slots into the existing registry (§5.4) like any
> other provider and does not otherwise depend on Phase 5.

---

## 9. Phase details
Expand Down Expand Up @@ -838,6 +846,52 @@ BTCPayServer solves the same problem; contrasting choices clarify ours:
is not reintroduced by accident when porting a provider idea from
BTCPay.

### 11.7 Nostr (subscribe, trusted-node relay mode) — issue #697

Every source above is an HTTP API. This one is not: it sources quotes from
**other Mostro nodes**, over Nostr, for operators whose network blocks the
HTTP price APIs outright (the motivating case: `api.yadio.io` is DNS-blocked
for Venezuelan ISPs) but who can still reach a Nostr relay.

- **Not a new publish path.** Mostro nodes already publish their aggregated
rates as a kind-30078 NIP-33 event (`d = "mostro-rates"`, content
`{"BTC": {ccy: price}}`) — see `docs/NOSTR_EXCHANGE_RATES.md` and
`nip33::new_exchange_rates_event`. The `nostr` provider is a **consumer**
of that exact same event, from other operators' nodes instead of its own.
- **Config (`[price.providers.nostr]`, §7):** no `url` — `trusted_nodes` (a
list of hex pubkeys) is the provider's only required field. Enabled
without at least one valid `trusted_nodes` pubkey is a startup error
(mirrors El Toque's missing-token fail-fast, §7).
- **Relays are not reconfigured.** The provider reuses the process-wide
Nostr client already connected to the node's own `[nostr]` relays — it
does not open a second set of connections or accept a separate relay list.
- **One-shot query per tick, not a persistent subscription.** `fetch()` is
called once per `update_interval_seconds` tick like every other provider
(spec §5.3); it issues a single `kind=30078, authors=trusted_nodes,
#d=mostro-rates` relay query bounded by a short fixed timeout, rather than
keeping an open `REQ` subscription running between ticks.
- **Freshest wins — no cross-node combine.** With several `trusted_nodes`
configured, the provider does **not** run the §6.2 median+outlier combine
across their individual events; it takes the single event with the
highest `created_at` among the trusted, valid ones. Rationale (confirmed
with the maintainer): the issue frames multiple `trusted_nodes` as
*redundancy* (if one is down or stale, use another), not as independent
samples to statistically combine, and skipping a second aggregation layer
inside a single provider keeps the adapter's contract identical to every
HTTP provider (one `ProviderQuotes` map out of `fetch()`, no new state).
A compromised single trusted node can therefore still push a bad price
with no in-provider outlier guard; operators mitigate by choosing
`trusted_nodes` they actually trust, same as any other single-sourced
provider.
- **Client-side pubkey verification.** Even though the relay-side `authors`
filter already restricts the query, the adapter re-checks each returned
event's `pubkey` against `trusted_nodes` before trusting its content —
the same verification `docs/NOSTR_EXCHANGE_RATES.md` requires of mobile
clients, applied here on the operator side.
- **Currency codes are re-upper-cased** (§6.6) even though a Mostro
publisher already emits uppercase codes — a third-party trusted node is
outside this codebase's control.

---

## 12. Tracking
Expand Down
11 changes: 11 additions & 0 deletions settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ port = 50051
# url = "https://tasas.eltoque.com"
# # token = "xxxx" # REQUIRED when enabled; provider refuses to start otherwise
# only = ["CUP", "MLC"]
#
# # Subscribe to prices published (kind 30078) by trusted Mostro nodes over
# # Nostr instead of an HTTP API — for operators in regions where price APIs
# # are DNS/IP blocked (docs/PRICE_PROVIDERS.md §11.7). Reuses the relays
# # already configured in [nostr]; no url needed. With several trusted_nodes,
# # the freshest valid event wins — not a cross-node combine.
# [price.providers.nostr]
# enabled = false
# trusted_nodes = [
# # hex pubkeys of Mostro nodes you trust to publish accurate rates
# ]

# Anti-abuse bond (issue #711). Opt-in, disabled by default. Uncomment to
# require a Lightning hold-invoice bond from takers and/or makers. See
Expand Down
71 changes: 63 additions & 8 deletions src/price/aggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ use super::provider::{ProviderId, ProviderQuotes, Quote};
/// value actually **survived** [`combine`]'s outlier filter — what the
/// Nostr `source` tag really means. The two can differ: with three
/// providers and one outlier the count is 3 but contributors is 2.
///
/// `nostr_anchor_dependent` is true when a fiat-cross (`PerBase`) path for
/// this currency resolved against an anchor that itself included `Nostr`
/// among its surviving direct contributors. The absolute per-BTC figure
/// then embeds a relayed rate even if `contributors` only names the
/// cross provider (e.g. El Toque × Nostr USD → CUP tagged `eltoque`).
/// [`crate::price::manager`] uses this so republication cannot re-stamp
/// that hybrid as a fresh local observation (PR #841 re-review).
#[derive(Debug, Clone, PartialEq)]
pub struct AggregateResult {
pub value: f64,
pub sources: u8,
pub contributors: Vec<ProviderId>,
pub nostr_anchor_dependent: bool,
}

/// Combine a currency's candidate per-BTC prices into one figure (spec §6.2).
Expand Down Expand Up @@ -111,8 +120,9 @@ pub fn resolve_per_base(
/// their provider id**.
/// 2. Per-currency **anchors** are the [`combine`]d direct quotes; fiat-cross
/// (`PerBase`) quotes are resolved against those anchors and attributed
/// to the fiat-cross provider (the anchor's own contributors are an
/// intermediate, not a contributor to the resolved currency).
/// to the fiat-cross provider (the anchor's own contributors stay out of
/// `contributors` — they are an intermediate of the cross math — but a
/// Nostr-touched anchor sets `nostr_anchor_dependent` on the result).
/// 3. Each currency's final value is the [`combine`] of its direct **and**
/// resolved candidates. `contributors` lists the providers whose value
/// survived the outlier filter ([`kept_contributors`]) — what the Nostr
Expand All @@ -138,20 +148,23 @@ pub fn aggregate_tick(
}
}

// Step 2: anchors = aggregated direct quotes (values only), then
// resolve cross quotes — attributing each resolved candidate to the
// fiat-cross provider that emitted it. Anchor contributors are *not*
// propagated into resolved-currency contributors: they are an
// intermediate of the cross math, not an upstream of the cross
// currency.
// Step 2: anchors = aggregated direct quotes, then resolve cross quotes.
// Track whether each anchor's surviving contributors include Nostr so a
// later PerBase resolution can mark the cross currency as
// `nostr_anchor_dependent` without putting Nostr into `contributors`
// (which would change the published `source` tag's meaning).
let mut anchors: HashMap<String, f64> = HashMap::new();
let mut anchor_uses_nostr: HashMap<String, bool> = HashMap::new();
for (currency, pairs) in &direct {
let values: Vec<f64> = pairs.iter().map(|(_, v)| *v).collect();
if let Some(v) = combine(&values, outlier_pct) {
anchors.insert(currency.clone(), v);
let kept = kept_contributors(pairs, outlier_pct);
anchor_uses_nostr.insert(currency.clone(), kept.contains(&ProviderId::Nostr));
}
}
let mut resolved: HashMap<String, Vec<(ProviderId, f64)>> = HashMap::new();
let mut resolved_nostr_anchor: HashMap<String, bool> = HashMap::new();
for (id, currency, base, value) in &per_base {
if let Some(anchor) = anchors.get(base) {
let candidate = value * anchor;
Expand All @@ -160,6 +173,9 @@ pub fn aggregate_tick(
.entry(currency.clone())
.or_default()
.push((*id, candidate));
if *anchor_uses_nostr.get(base).unwrap_or(&false) {
resolved_nostr_anchor.insert(currency.clone(), true);
}
}
}
}
Expand Down Expand Up @@ -191,6 +207,7 @@ pub fn aggregate_tick(
value,
sources,
contributors,
nostr_anchor_dependent: *resolved_nostr_anchor.get(currency).unwrap_or(&false),
},
);
}
Expand Down Expand Up @@ -386,6 +403,44 @@ mod tests {
out["CUP"].contributors,
vec![ProviderId::Yadio, ProviderId::ElToque]
);
assert!(
!out["CUP"].nostr_anchor_dependent,
"Yadio/CoinGecko USD anchor is local — CUP cross is not Nostr-tainted"
);
}

#[test]
fn aggregate_tick_marks_cross_resolved_via_nostr_usd_as_anchor_dependent() {
// Nostr is the only USD source; El Toque CUP/USD resolves against it.
// contributors stay [ElToque] (anchor providers are intermediates),
// but nostr_anchor_dependent must be set so republication cannot
// re-stamp the hybrid as a fresh local observation.
let mut nostr = ProviderQuotes::new();
nostr.insert("USD".into(), Quote::PerBtc(50_000.0));
let mut eltoque = ProviderQuotes::new();
eltoque.insert(
"CUP".into(),
Quote::PerBase {
base: "USD".into(),
value: 400.0,
},
);

let out = aggregate_tick(
&[(ProviderId::Nostr, nostr), (ProviderId::ElToque, eltoque)],
PCT,
);

approx(out["CUP"].value, 20_000_000.0);
assert_eq!(out["CUP"].contributors, vec![ProviderId::ElToque]);
assert!(
out["CUP"].nostr_anchor_dependent,
"CUP absolute value embeds Nostr USD and must be flagged"
);
assert!(
!out["USD"].nostr_anchor_dependent,
"direct Nostr USD is Nostr-only via contributors, not via anchor taint"
);
}

#[test]
Expand Down
93 changes: 87 additions & 6 deletions src/price/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ pub struct ProviderConfig {
/// Whether this provider participates in aggregation.
#[serde(default)]
pub enabled: bool,
/// Primary base URL.
/// Primary base URL. Not needed by a provider sourcing quotes over Nostr
/// instead of HTTP (see `trusted_nodes`) — defaults to empty so those
/// providers can omit it.
#[serde(default)]
pub url: String,
/// Ordered mirrors tried when `url` fails this tick (spec §7).
#[serde(default)]
Expand All @@ -65,6 +68,10 @@ pub struct ProviderConfig {
/// Exclude these currencies from this provider (spec §6.6).
#[serde(default)]
pub except: Option<Vec<String>>,
/// Trusted node pubkeys (hex) to source quotes from over Nostr, instead
/// of an HTTP `url` (the `nostr` provider; see §11.7).
#[serde(default)]
pub trusted_nodes: Vec<String>,
}

impl ProviderConfig {
Expand All @@ -78,11 +85,25 @@ impl ProviderConfig {
(see docs/PRICE_PROVIDERS.md §7)"
));
}
// `trusted_nodes` only excuses a missing `url` for the `nostr`
// provider — it's the only adapter that reads it (§11.7). Any other
// id is still HTTP-only, so exempting it here would let a typo'd or
// copy-pasted `trusted_nodes` mask a missing `url` past startup and
// into a confusing per-tick HTTP failure instead.
if self.enabled && self.url.trim().is_empty() {
return Err(format!(
"price provider '{id}': enabled provider must have a non-empty `url` \
(see docs/PRICE_PROVIDERS.md §7)"
));
if id == "nostr" {
if self.trusted_nodes.is_empty() {
return Err(format!(
"price provider '{id}': enabled provider must have a non-empty \
`trusted_nodes` (see docs/PRICE_PROVIDERS.md §11.7)"
));
}
} else {
return Err(format!(
"price provider '{id}': enabled provider must have a non-empty `url` \
(see docs/PRICE_PROVIDERS.md §7)"
));
}
}
Ok(())
}
Expand Down Expand Up @@ -217,13 +238,21 @@ enabled = false
url = "https://tasas.eltoque.com"
token = "secret"
only = ["CUP", "MLC"]

[price.providers.nostr]
enabled = false
trusted_nodes = ["82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390"]
"#;
let parsed: Stub = toml::from_str(toml_str).unwrap();
let p = parsed.price;
// Overridden value + defaulted siblings.
assert_eq!(p.update_interval_seconds, 60);
assert_eq!(p.max_price_staleness_seconds, 1800);
assert_eq!(p.providers.len(), 3);
assert_eq!(p.providers.len(), 4);

let nostr = &p.providers["nostr"];
assert!(nostr.url.is_empty());
assert_eq!(nostr.trusted_nodes.len(), 1);

let ca = &p.providers["currency_api"];
assert!(ca.enabled);
Expand Down Expand Up @@ -251,6 +280,7 @@ only = ["CUP", "MLC"]
token: None,
only: Some(vec!["CUP".into()]),
except: Some(vec!["MLC".into()]),
trusted_nodes: vec![],
};
assert!(cfg.validate("eltoque").is_err());
}
Expand Down Expand Up @@ -298,6 +328,7 @@ only = ["CUP", "MLC"]
token: None,
only: None,
except: None,
trusted_nodes: vec![],
};
assert!(blank.validate("yadio").is_err());
// A disabled provider with a blank url is allowed (inert).
Expand All @@ -308,6 +339,55 @@ only = ["CUP", "MLC"]
disabled.validate("yadio").unwrap();
}

#[test]
fn enabled_provider_with_trusted_nodes_needs_no_url() {
let cfg = ProviderConfig {
enabled: true,
url: String::new(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
trusted_nodes: vec!["a".repeat(64)],
};
cfg.validate("nostr").unwrap();
}

#[test]
fn trusted_nodes_does_not_excuse_a_missing_url_on_a_non_nostr_provider() {
// Regression: `trusted_nodes` only means anything to the `nostr`
// adapter. A copy-paste onto e.g. `yadio` must not silently pass
// validation and defer the failure to a confusing per-tick HTTP
// error against an empty URL.
let cfg = ProviderConfig {
enabled: true,
url: String::new(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
trusted_nodes: vec!["a".repeat(64)],
};
assert!(cfg.validate("yadio").is_err());
}

#[test]
fn enabled_provider_without_url_or_trusted_nodes_is_rejected() {
let cfg = ProviderConfig {
enabled: true,
url: String::new(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
trusted_nodes: vec![],
};
assert!(cfg.validate("nostr").is_err());
}

#[test]
fn no_scoping_allows_everything() {
let cfg = ProviderConfig {
Expand All @@ -318,6 +398,7 @@ only = ["CUP", "MLC"]
token: None,
only: None,
except: None,
trusted_nodes: vec![],
};
assert!(cfg.allows_currency("USD"));
assert!(cfg.allows_currency("CUP"));
Expand Down
Loading