Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ tokio = { version = "1.47.1", features = ["full", "test-util", "macros"] }
axum = "0.8.4"
tower-http = { version = "0.6.6", features = ["cors"] }
bech32 = "0.11.0"
nostr-relay-builder = "0.44.1"

[build-dependencies]
tonic-prost-build = "0.14.1"
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
8 changes: 8 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ pub use types::{
// They are shared across the application using Arc and Mutex/RwLock for thread safety
pub static MOSTRO_CONFIG: OnceLock<Settings> = OnceLock::new();
pub static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
/// Dedicated Nostr client for the price provider's kind-30078 queries.
///
/// Kept separate from [`NOSTR_CLIENT`] so `verify_subscriptions(true)` (and
/// the REQ `limit` it enforces) applies only to price fetches — not to the
/// daemon's long-lived `.limit(0)` inbox subscription in `main.rs`, where
/// pre-EOSE filter verification would reject matching trade messages
/// (hermeme, PR #841).
pub static PRICE_NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
pub static LN_STATUS: OnceLock<LnStatus> = OnceLock::new();
pub static DB_POOL: OnceLock<Arc<sqlx::SqlitePool>> = OnceLock::new();

Expand Down
125 changes: 116 additions & 9 deletions src/price/aggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! function is a deterministic transform over in-memory inputs, which is
//! what makes the numeric heart of the feature exhaustively testable.

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

use super::provider::{ProviderId, ProviderQuotes, Quote};

Expand All @@ -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,27 @@ 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();
// Providers whose *resolved* candidate for a currency came from a
// Nostr-touched anchor. Taint is applied only if that provider later
// survives the target currency's outlier filter (step 3) — a discarded
// wild cross must not mark an otherwise-local aggregate dependent.
let mut nostr_anchored_resolvers: HashMap<String, HashSet<ProviderId>> = 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 +177,12 @@ pub fn aggregate_tick(
.entry(currency.clone())
.or_default()
.push((*id, candidate));
if *anchor_uses_nostr.get(base).unwrap_or(&false) {
nostr_anchored_resolvers
.entry(currency.clone())
.or_default()
.insert(*id);
}
}
}
}
Expand All @@ -185,12 +208,16 @@ pub fn aggregate_tick(
.filter(|x| x.is_finite() && **x > 0.0)
.count()
.min(u8::MAX as usize) as u8;
let nostr_anchor_dependent = nostr_anchored_resolvers
.get(currency)
.is_some_and(|tainted| contributors.iter().any(|c| tainted.contains(c)));
out.insert(
currency.clone(),
AggregateResult {
value,
sources,
contributors,
nostr_anchor_dependent,
},
);
}
Expand Down Expand Up @@ -386,6 +413,86 @@ 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]
fn aggregate_tick_nostr_anchor_taint_clears_when_cross_is_outlier() {
// Local direct quotes agree near 100; a Nostr-anchored El Toque
// cross at 10000 is discarded by the 5% outlier band. The final
// value is fully local — it must NOT stay nostr_anchor_dependent
// (ermeme follow-up on a8a6aef2).
let mut nostr = ProviderQuotes::new();
nostr.insert("USD".into(), Quote::PerBtc(50_000.0));
let mut yadio = ProviderQuotes::new();
yadio.insert("CUP".into(), Quote::PerBtc(100.0));
let mut coingecko = ProviderQuotes::new();
coingecko.insert("CUP".into(), Quote::PerBtc(101.0));
let mut eltoque = ProviderQuotes::new();
eltoque.insert(
"CUP".into(),
Quote::PerBase {
base: "USD".into(),
value: 0.2, // 0.2 * 50_000 = 10_000 — wildly above local CUP
},
);

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

approx(out["CUP"].value, 100.5);
assert_eq!(
out["CUP"].contributors,
vec![ProviderId::Yadio, ProviderId::CoinGecko]
);
assert!(
!out["CUP"].nostr_anchor_dependent,
"outlier-discarded Nostr-anchored cross must not taint a local aggregate"
);
}

#[test]
Expand Down
Loading