Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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