Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 20 additions & 1 deletion docs/PRICE_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (§6.6)
| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | — | done (PR #753) |
| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | done (PR #753) |
| 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) → real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | in review |
| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | pending |
| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | in review (see §11.3) |
| 4 | Unify `get_market_quote` onto the cache; staleness TTL enforcement (`PriceTooStale`) at create/take | 2 | pending |
| 5 | Nostr aggregated publishing + token/paid-provider support polish + info-event exposure + retire `bitcoin_price.rs` + ops docs | 3, 4 | pending |

Expand Down Expand Up @@ -731,6 +731,25 @@ a BTC price source**. Therefore:
ever switched to official, we would scope its CUP out too and lean on
El Toque.

> **Phase 3 shipped status.** The El Toque adapter
> (`src/price/providers/eltoque.rs`) is wired with **anchor = USD only**
> (Q2 above declined for this phase). The request and response are confirmed
> against the live API:
> - **Request:** `GET {url}/v1/trmi?date_from=…&date_to=…` with
> `Authorization: Bearer <token>`. The endpoint requires a
> `[date_from, date_to]` range (`YYYY-MM-DD HH:MM:SS`, URL-encoded) and
> returns the most recent rate within it, so `fetch` queries a rolling
> 48h window ending "now".
> - **Response:** a CUP-denominated `tasas` object
> (`{"tasas":{"USD":490.0,"MLC":200.0,"ECU":540.0,…}}`, where El Toque uses
> `ECU` for the euro) plus the timestamp of the returned rate
> (`date`/`hour`/`minutes`/`seconds`, which the parser ignores). The parse
> path applies the §11.3 cross math and is fully unit-tested against a
> captured fixture (`tests/fixtures/price/eltoque_trmi.json`).
>
> Q1/Q3 remain open. Keep `enabled = false` in production until a token is
> provisioned and the operator opts in.

### 11.4 Blockchain.com (direct, 28 major fiats, NO CUP/MLC)
- `GET https://blockchain.info/ticker` →
`{ "USD": { "15m":76273, "last":76273, "buy":…, "sell":…, "symbol":"USD" }, … }`.
Expand Down
14 changes: 11 additions & 3 deletions settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,17 @@ port = 50051
# enabled = true
# url = "https://blockchain.info"
#
# # El Toque (fiat-cross CUP/MLC, requires a token) is wired in Phase 3 —
# # see docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for
# # the `only` / `except` per-provider currency scoping rules.
# # El Toque — informal-market CUP/MLC (fiat-cross, resolved against the
# # aggregated USD/BTC anchor; §6.3, §11.3). Opt-in: requires a free Bearer
# # token. Scoped to CUP/MLC only — that is all this source contributes.
# # NOTE: the request wiring is PROVISIONAL pending a confirmed payload from
# # the token-gated API — keep `enabled = false` in production until then
# # (see docs/PRICE_PROVIDERS.md §11.3).
# [price.providers.eltoque]
# enabled = false
# url = "https://tasas.eltoque.com"
# # token = "xxxx" # REQUIRED when enabled; provider refuses to start otherwise
# only = ["CUP", "MLC"]

# 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
27 changes: 26 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,32 @@ fn install_price_manager() -> std::result::Result<(), Box<dyn std::error::Error>

let mostro_settings = Settings::get_mostro();
let price_settings = match Settings::get_price() {
Some(p) => p.clone(),
Some(p) => {
// Multi-source mode: the `[price.providers.*]` tables drive
// aggregation, so the legacy `[mostro].bitcoin_price_api_url` is
// not consulted here. Surface that explicitly so an operator who
// still has the legacy key set isn't misled into thinking it
// takes effect — name the providers actually in play instead.
let mut enabled: Vec<&str> = p
.providers
.iter()
.filter(|(_, cfg)| cfg.enabled)
.map(|(id, _)| id.as_str())
.collect();
enabled.sort_unstable();
let enabled = if enabled.is_empty() {
"<none>".to_string()
} else {
enabled.join(", ")
};
tracing::warn!(
"price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \
aggregation because `[price]` is configured; using enabled providers: {}",
mostro_settings.bitcoin_price_api_url,
enabled,
Comment on lines +223 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging bitcoin_price_api_url verbatim.

Logging the full legacy URL can leak credentials if operators embed auth data (userinfo or query tokens) in the URL. Log a redacted/sanitized form instead (e.g., scheme + host + path, strip userinfo/query/fragment).

🔒 Suggested fix
+            let legacy_url_for_log = reqwest::Url::parse(&mostro_settings.bitcoin_price_api_url)
+                .map(|mut u| {
+                    let _ = u.set_username("");
+                    let _ = u.set_password(None);
+                    u.set_query(None);
+                    u.set_fragment(None);
+                    u.to_string()
+                })
+                .unwrap_or_else(|_| "<invalid_url>".to_string());
             tracing::warn!(
                 "price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \
                  aggregation because `[price]` is configured; using enabled providers: {}",
-                mostro_settings.bitcoin_price_api_url,
+                legacy_url_for_log,
                 enabled,
             );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tracing::warn!(
"price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \
aggregation because `[price]` is configured; using enabled providers: {}",
mostro_settings.bitcoin_price_api_url,
enabled,
let legacy_url_for_log = reqwest::Url::parse(&mostro_settings.bitcoin_price_api_url)
.map(|mut u| {
let _ = u.set_username("");
let _ = u.set_password(None);
u.set_query(None);
u.set_fragment(None);
u.to_string()
})
.unwrap_or_else(|_| "<invalid_url>".to_string());
tracing::warn!(
"price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \
aggregation because `[price]` is configured; using enabled providers: {}",
legacy_url_for_log,
enabled,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.rs` around lines 223 - 227, The tracing::warn! call is logging the
full bitcoin_price_api_url which may contain embedded credentials (userinfo,
query tokens, etc.). Parse the bitcoin_price_api_url as a URL object and
construct a sanitized version containing only the scheme, host, and path while
stripping userinfo, query parameters, and fragments. Replace the direct
mostro_settings.bitcoin_price_api_url reference in the log message with the
sanitized URL string to prevent credential leakage in logs.

);
p.clone()
}
None => synthesise_legacy_price_settings(
&mostro_settings.bitcoin_price_api_url,
mostro_settings.exchange_rates_update_interval_seconds,
Expand Down
64 changes: 39 additions & 25 deletions src/price/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
//! providers, aggregate, and write the store; consumers (`get_bitcoin_price`,
//! `BitcoinPriceManager::get_price`) read through [`PriceManager::get_price`].
//!
//! ## Phase 1 / 2 invariants (spec §9)
//! ## Phase 1 / 2 / 3 invariants (spec §9)
//! - The registry is built from `[price]`; the direct quoters (Yadio,
//! CoinGecko, currency-api, Blockchain) are wired, El Toque lands in
//! Phase 3.
//! CoinGecko, currency-api, Blockchain) and the El Toque fiat-cross
//! quoter (Phase 3, CUP/MLC) are all wired.
//! - Staleness is **logged, not enforced**: a value older than one
//! `update_interval` emits a `warn!` but still returns to the caller, so
//! Phases 1–3 never refuse an order that would have priced today.
Expand Down Expand Up @@ -40,6 +40,7 @@ use super::provider::{PriceProvider, ProviderError, ProviderHealth, ProviderId,
use super::providers::blockchain::BlockchainProvider;
use super::providers::coingecko::CoinGeckoProvider;
use super::providers::currency_api::CurrencyApiProvider;
use super::providers::eltoque::ElToqueProvider;
use super::providers::yadio::YadioProvider;
use super::store::{PriceError, PriceStore};

Expand Down Expand Up @@ -560,13 +561,10 @@ fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Result<Box<dyn PriceP
ProviderId::CoinGecko => Ok(Box::new(CoinGeckoProvider::new(cfg))),
ProviderId::CurrencyApi => Ok(Box::new(CurrencyApiProvider::new(cfg))),
ProviderId::Blockchain => Ok(Box::new(BlockchainProvider::new(cfg))),
// El Toque lands in Phase 3. Reject explicitly so an over-eager
// config doesn't silently spawn nothing.
ProviderId::ElToque => Err(format!(
"price: provider `{id}` is configured (enabled) but not yet implemented in \
this release — disable it or remove it from `[price.providers]` \
(see docs/PRICE_PROVIDERS.md §7)"
)),
// El Toque (fiat-cross CUP/MLC). `new` returns `Err` when the
// required Bearer token is missing, so an enabled-but-unconfigured
// provider fails fast at startup (spec §7).
ProviderId::ElToque => Ok(Box::new(ElToqueProvider::new(cfg)?)),
}
}

Expand Down Expand Up @@ -831,23 +829,39 @@ mod tests {
cfg.validate().expect("synthesised config must validate");
}

fn eltoque_cfg(token: Option<&str>) -> ProviderConfig {
ProviderConfig {
enabled: true,
url: "https://tasas.eltoque.com".into(),
fallback_urls: vec![],
api_key: None,
token: token.map(String::from),
only: Some(vec!["CUP".into(), "MLC".into()]),
except: None,
}
}

#[test]
fn from_settings_rejects_unimplemented_provider_id() {
// An enabled provider whose adapter isn't yet wired (El Toque,
// Phase 3) must fail at startup, not silently produce nothing.
fn from_settings_builds_eltoque_with_token() {
// Phase 3: El Toque is now wired. With its required Bearer token it
// builds into the registry like any other provider.
let mut settings = PriceSettings::default();
settings.providers.insert(
ProviderId::ElToque.to_string(),
ProviderConfig {
enabled: true,
url: "https://tasas.eltoque.com".into(),
fallback_urls: vec![],
api_key: None,
token: Some("x".into()),
only: None,
except: None,
},
);
settings
.providers
.insert(ProviderId::ElToque.to_string(), eltoque_cfg(Some("tok")));
let m = PriceManager::from_settings(settings).expect("eltoque builds with a token");
assert_eq!(m.providers.len(), 1);
assert_eq!(m.providers[0].id, ProviderId::ElToque);
}

#[test]
fn from_settings_rejects_eltoque_without_token() {
// Spec §7: an enabled El Toque missing its required Bearer token must
// fail fast at startup, not silently produce nothing.
let mut settings = PriceSettings::default();
settings
.providers
.insert(ProviderId::ElToque.to_string(), eltoque_cfg(None));
assert!(PriceManager::from_settings(settings).is_err());
}

Expand Down
Loading