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
1 change: 1 addition & 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 @@ -74,6 +74,7 @@ mostro-core = { version = "0.12.1", features = ["sqlx"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
async-trait = "0.1.83"
futures = "0.3.31"
clap = { version = "4.5.45", features = ["derive"] }
lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] }
once_cell = "1.20.2"
Expand Down
6 changes: 3 additions & 3 deletions docs/PRICE_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,9 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (§6.6)

| Phase | PR scope | Depends on | Status |
|------:|----------|------------|--------|
| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | — | pending |
| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | pending |
| 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 | pending |
| 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 |
| 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
28 changes: 25 additions & 3 deletions settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,31 @@ port = 50051
# enabled = true
# url = "https://api.yadio.io"
#
# # The keyless backups, El Toque, etc. are wired in Phases 2 and 3 — see
# # docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for the
# # `only` / `except` per-provider currency scoping rules.
# [price.providers.coingecko]
# enabled = true
# url = "https://api.coingecko.com/api/v3"
# # api_key = "CG-xxxx" # optional demo/pro key; raises rate limits. Pro
# # plans use url = "https://pro-api.coingecko.com/api/v3" (the adapter picks
# # the matching auth header from the host).
#
# # Keyless, CDN-hosted, 300+ currencies incl. CUP at the OFFICIAL rate — so
# # CUP/MLC are excluded to avoid mixing official with the informal-market
# # sources (docs/PRICE_PROVIDERS.md §6.6).
# [price.providers.currency_api]
# enabled = true
# url = "https://currency-api.pages.dev/v1"
# # Ordered mirrors tried in sequence when `url` fails this tick.
# fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"]
# except = ["CUP", "MLC"]
#
# # Keyless, ~28 major fiats, no CUP/MLC. Mid-market (`last`) only.
# [price.providers.blockchain]
# 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.

# 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
117 changes: 117 additions & 0 deletions src/price/fiat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//! The §6.6 fiat allowlist.
//!
//! Providers return junk for our purposes — `currency-api` ships **324+**
//! entries including crypto (`eth`, `bnb`, `ada`) and non-ISO codes, and
//! Yadio quotes metals (`XAU`, `XAG`, `XPT`) and BTC itself. The aggregator
//! restricts to the known fiat set below; everything else is dropped before
//! aggregation and before the Nostr publish, keeping the store and the
//! kind-30078 event lean (spec §6.6).
//!
//! The set is **ISO-4217 active codes** plus the non-ISO codes that are
//! nonetheless real, Mostro-traded fiat — all of them present in Yadio's
//! live feed today:
//!
//! - `IRT` — Iranian toman (the everyday unit; `IRR` is the official rial),
//! - `GGP` / `IMP` / `JEP` — Guernsey / Manx / Jersey pounds (GBP-pegged),
//! - `MLC` — Cuban MLC (the spec's motivating fiat-cross case, §11.3).
//!
//! Deliberately **excluded**: `VEF` (the pre-redenomination Venezuelan
//! code some APIs still report) — its scale diverges from the ISO `VES` by
//! orders of magnitude, so letting it in could price an order with a
//! garbage rate. Same reasoning as scoping out `currency-api`'s official
//! CUP: a different unit is worse than no data.
//!
//! Against the live Yadio feed this drops exactly `BTC`, `XAU`, `XAG`,
//! `XPT` — the non-fiat tail — and nothing else, so Phase 2 does not
//! silently remove a currency a node was publishing yesterday.

/// Sorted (binary-searchable) allowlist. Keep alphabetical — there is a
/// test asserting the order so `binary_search` stays correct.
const KNOWN_FIAT: &[&str] = &[
"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT",
"BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD",
"CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD",
"EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF",
"GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "IMP", "INR", "IQD", "IRR", "IRT",
"ISK", "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD",
"KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MLC", "MMK",
"MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK",
"NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD",
"RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP",
"STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS",
"UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "VUV", "WST", "XAF", "XCD", "XCG", "XOF",
"XPF", "YER", "ZAR", "ZMW", "ZWL",
];

/// Whether `code` (any casing) is a known fiat currency. Non-fiat codes are
/// dropped at the manager boundary before aggregation (spec §6.6).
pub fn is_known_fiat(code: &str) -> bool {
let upper = code.to_uppercase();
KNOWN_FIAT.binary_search(&upper.as_str()).is_ok()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn allowlist_is_sorted_and_deduped() {
// binary_search precondition; a mis-sorted insert would silently
// make some legitimate currency "unknown".
let mut sorted = KNOWN_FIAT.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(KNOWN_FIAT, sorted.as_slice());
}

#[test]
fn accepts_major_and_motivating_codes() {
for code in ["USD", "EUR", "ARS", "CUP", "MLC", "IRT", "VES", "JPY"] {
assert!(is_known_fiat(code), "{code} must be known fiat");
}
// Case-insensitive: currency-api ships lowercase.
assert!(is_known_fiat("usd"));
assert!(is_known_fiat("cup"));
}

#[test]
fn rejects_crypto_metals_and_junk() {
// The §6.6 motivating junk: crypto from currency-api, metals and
// the BTC self-quote from Yadio.
// `VEF` is rejected on purpose: pre-redenomination unit, not `VES`.
for code in [
"BTC", "ETH", "BNB", "ADA", "XAU", "XAG", "XPT", "1INCH", "VEF", "",
] {
assert!(!is_known_fiat(code), "{code} must be rejected");
}
}

#[test]
fn live_yadio_codes_survive_except_non_fiat() {
// Captured from the live Yadio feed 2026-06-11 (128 codes). The
// allowlist must pass every one of them except the four non-fiat
// entries — Phase 2 must not silently drop a currency nodes were
// publishing yesterday.
let yadio_live = [
"AED", "ALL", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN",
"BHD", "BIF", "BMD", "BOB", "BRL", "BSD", "BTC", "BTN", "BWP", "BYN", "BZD", "CAD",
"CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP",
"DZD", "EGP", "ERN", "ETB", "EUR", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD",
"GNF", "GTQ", "HKD", "HNL", "HUF", "IDR", "ILS", "IMP", "INR", "IRR", "IRT", "ISK",
"JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KMF", "KRW", "KYD", "KZT", "LBP", "LKR",
"LSL", "MAD", "MGA", "MLC", "MOP", "MRU", "MWK", "MXN", "MYR", "NAD", "NGN", "NIO",
"NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PHP", "PKR", "PLN", "PYG", "QAR", "RON",
"RSD", "RUB", "RWF", "SAR", "SEK", "SGD", "SHP", "SYP", "SZL", "THB", "TMT", "TND",
"TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "XAF",
"XAG", "XAU", "XCD", "XCG", "XOF", "XPT", "ZAR", "ZMW",
];
let non_fiat = ["BTC", "XAU", "XAG", "XPT"];
for code in yadio_live {
if non_fiat.contains(&code) {
assert!(!is_known_fiat(code), "{code} is non-fiat, must drop");
} else {
assert!(is_known_fiat(code), "{code} from live Yadio must pass");
}
}
}
}
Loading