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
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