mostro 0.18.0

Lightning Network peer-to-peer nostr platform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
# Multi-Source Price Providers — Implementation Spec

> Implementation guide for removing the single-source (Yadio) price
> dependency. This document is the single source of truth as the feature
> is rolled out across several PRs. Each phase below maps to one **small,
> atomic PR** that can be reviewed, tested, and released independently.
>
> **Status of design decisions:** the aggregation method, per-currency
> averaging, staleness policy, and live-path unification were settled with
> the maintainer (see §4). One item is still flagged for confirmation: the
> El Toque fiat-cross modelling in §11.3.

## 1. Goal

Replace the hard dependency on the **Yadio** API for BTC/fiat prices with
a **multi-source price module**. The module:

- Queries **several independent price APIs** behind a single common
  interface (one `.rs` file per API), so `mostrod` carries **no
  per-API code** outside each provider's adapter.
- **Aggregates per currency** across every API that reports it and is
  currently healthy, using a robust median + outlier-guard (see §6).
- **Degrades gracefully**: a down API is simply ignored for the current
  aggregation; a currency keeps serving its last-known-good value until a
  staleness TTL elapses.
- Treats specialised sources naturally: a source that only reports CUP/MLC
  (El Toque) only ever contributes to CUP/MLC; a source with no CUP/MLC
  (CoinGecko) only contributes to the currencies it does report.

Today Yadio is a **single point of failure** for 100+ fiat currencies:
when `api.yadio.io` is down, every market-priced order across every
currency is stuck on stale data. This module removes that.

## 2. Current state (what this replaces)

Two Yadio-only integration points exist today:

| Path | Code | Trigger | Endpoint |
|------|------|---------|----------|
| **Cached** | `BitcoinPriceManager` (`src/bitcoin_price.rs`) | scheduler `job_update_bitcoin_prices` every `exchange_rates_update_interval_seconds` | Yadio `GET /exrates/BTC``{ "BTC": { "USD": 50000, … } }` |
| **Live** | `get_market_quote` (`src/util.rs`) | every market-priced take via `get_market_amount_and_fee` | Yadio `GET /convert/{amt}/{ccy}/BTC` |

- The cached path stores a `HashMap<String, f64>` (currency → fiat-per-BTC)
  in a static `BITCOIN_PRICES`, read by `get_bitcoin_price()`
  (`util.rs`), used at order creation (`app/order.rs`, when `amount == 0`).
- The cached path also publishes rates to Nostr as a NIP-33 kind-30078
  event with a `source: yadio` tag (see `docs/NOSTR_EXCHANGE_RATES.md`).
- The live path does its own retry loop (`retries_yadio_request`, 4
  tries) and is the second place Yadio is hard-wired.

Both are absorbed into the new module; see Phases 1 and 4.

## 3. Guiding principles

1. **One interface, many providers.** Every API is a `PriceProvider`
   implementation in its own file under `src/price/providers/`. Adding a
   new API is a new adapter file, one registry line, and one config
   entry — never a change to the aggregation core, the scheduler, or any
   handler. The full checklist is §5.4.
2. **Per-currency, source-agnostic aggregation.** The aggregate for a
   currency is computed only from the providers that report it and are
   healthy *right now*. There is no global "primary" provider; Yadio is
   just one source among several.
3. **Never let one bad source move the market.** Aggregation is
   outlier-resistant (§6). A single API returning a corrupt or stale value
   must not drag the result.
4. **Degrade, don't fail.** A down API is skipped for the current tick. A
   currency with no fresh source falls back to its last-known-good value
   until a configurable staleness TTL; only then is it refused.
5. **Opt-in per provider; safe defaults.** A node runs whatever subset of
   providers it configures. The default config reproduces today's
   behaviour closely (Yadio enabled) plus at least one keyless backup, so
   upgrading is strictly more resilient with zero required config.
6. **Secrets stay secret.** Paid providers take an API token from config
   only; tokens are never logged, never published to Nostr, never put on
   an audit event.
7. **Tests accompany every phase.** Aggregation math is pure and unit
   tested exhaustively; provider adapters are tested against captured
   sample payloads. `cargo test`, `cargo clippy --all-targets
   --all-features`, and `cargo fmt` stay green.

## 4. Settled design decisions

Confirmed with the maintainer before writing this spec:

- **Aggregation = median + outlier guard** (§6.2). Not a plain mean.
- **Per-currency averaging, no authoritative override.** All healthy
  sources that report a currency are combined equally; e.g. CUP combines
  Yadio + El Toque by aggregation, and if one is down the other is used.
  No per-currency "this source wins" mechanism in v1.
- **Staleness = last-known-good + TTL** (§6.4). Serve the last value per
  currency up to `max_price_staleness_seconds`; past that, refuse
  market-priced operations for that currency with a clear error.
- **Unify the live path onto the cache** (§9, Phase 4).
  `get_market_quote` reads the aggregated cache instead of calling Yadio
  `/convert` per take. No per-take HTTP, one multi-source path.

## 5. Architecture overview

### 5.1 Module layout

```text
src/price/
  mod.rs            -- PriceManager: public API + scheduler entry point
  provider.rs       -- PriceProvider trait, Quote, ProviderId, health/circuit-breaker
  aggregate.rs      -- pure aggregation (anchor resolution, median+outlier, staleness)
  store.rs          -- in-memory aggregated-price store (RwLock<HashMap<..>>)
  config.rs         -- typed [price] config + per-provider sub-config
  providers/
    mod.rs
    yadio.rs        -- direct BTC quoter, 120+ currencies incl. CUP/MLC
    coingecko.rs    -- direct BTC quoter, many currencies, no CUP/MLC
    eltoque.rs      -- fiat-cross quoter, CUP/MLC only (needs USD anchor, §11.3)
```

`src/bitcoin_price.rs` is retired at the end of the rollout (Phase 5);
its public surface (`BitcoinPriceManager::get_price`, `update_prices`) is
re-exported as thin shims during the transition so consumers migrate one
at a time.

### 5.2 The provider interface

```rust
/// A single currency quote from a provider.
pub enum Quote {
    /// Fiat units per 1 BTC (Yadio, CoinGecko). Directly aggregatable.
    PerBtc(f64),
    /// `value` units of this currency per 1 unit of `base` currency
    /// (El Toque: CUP per USD). Resolved to a per-BTC figure by
    /// multiplying by the aggregated `base`/BTC price (§6.3). Lets a
    /// fiat-to-fiat source contribute without itself knowing the BTC price.
    PerBase { base: String, value: f64 },
}

/// Result of one provider poll: currency code -> quote.
pub type ProviderQuotes = std::collections::HashMap<String, Quote>;

#[async_trait::async_trait]
pub trait PriceProvider: Send + Sync {
    /// Stable identifier used in logs, config keys, health tracking, and
    /// the Nostr `source` metadata.
    fn id(&self) -> ProviderId;

    /// Fetch the latest quotes. Returns only the currencies this provider
    /// actually reports; a network/parse failure is an `Err` (the whole
    /// provider is skipped for this tick, never a partial map with bogus
    /// values).
    async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError>;
}
```

- `mostrod` only ever holds `Vec<Box<dyn PriceProvider>>`. The aggregation
  core and scheduler are provider-agnostic.
- A provider returning `Quote::PerBtc` is a **direct** quoter; a provider
  returning `Quote::PerBase` is a **fiat-cross** quoter (§6.3, §11.3).
- Partial coverage is the norm: each provider returns only what it has.
  El Toque returns `{ CUP, MLC }`; CoinGecko returns its supported set
  (no CUP/MLC); Yadio returns 120+. The aggregator unions them per
  currency.

### 5.3 The aggregation pipeline (one scheduler tick)

```text
                 ┌─ yadio.fetch()    ─┐  (PerBtc)
poll all healthy ├─ coingecko.fetch()─┤  (PerBtc)   each with per-provider
providers, in    └─ eltoque.fetch()  ─┘  (PerBase)  timeout + circuit breaker
parallel
 1. collect PerBtc quotes  → per-currency candidate lists (the "anchors")
 2. resolve PerBase quotes → currency/BTC = value × aggregate(base/BTC)   (§6.3)
 3. aggregate per currency → median + outlier guard / mean / single       (§6.2)
 4. write store: { currency -> AggregatedPrice { value, as_of: now, sources } }
    - currencies with zero fresh contributors this tick keep their prior
      AggregatedPrice (last-known-good, old `as_of`).
                 reads: PriceManager::get_price(ccy) -> staleness-checked value           (§6.4)
```

### 5.4 Adding a new provider (the extension contract)

This is the payoff of the whole design: a new API is **one small adapter
file + one enum variant + one registry line + one config block + one
fixture test**. You touch *only* those; the aggregation core, the store,
the scheduler, and every order handler stay untouched.

**Step 1 — write the adapter.** `src/price/providers/<id>.rs`,
implementing `PriceProvider`. Map only the currencies the API actually
reports, and pick the quote flavour:

- a **direct** BTC quoter returns `Quote::PerBtc(fiat_per_btc)`;
- a **fiat-cross** quoter returns `Quote::PerBase { base, value }`
  (resolved against the aggregated `base`/BTC anchor, §6.3).

Two normalisation rules every adapter must follow (§6.6):

- **Canonicalise currency codes to uppercase ISO-4217.** Providers
  disagree on casing — `currency-api` ships lowercase (`"usd"`),
  Yadio/Blockchain ship uppercase (`"USD"`). The aggregator keys on the
  code, so an un-normalised adapter would silently fail to combine its
  values with everyone else's.
- **Emit one mid-market price per currency.** Ignore any bid/ask/spread
  fields (e.g. Blockchain's `buy`/`sell`; use `last`). Mostro prices at
  mid-market and applies its own premium/fee separately — providers must
  not bake in a spread (§6.6, §11.6).

```rust
// src/price/providers/myapi.rs
use async_trait::async_trait;
use crate::price::provider::{
    PriceProvider, ProviderConfig, ProviderError, ProviderId, ProviderQuotes, Quote,
};

pub struct MyApiProvider {
    url: String,
    // token / api_key pulled from ProviderConfig if the API needs one
}

impl MyApiProvider {
    pub fn new(cfg: &ProviderConfig) -> Self {
        Self { url: cfg.url.clone() }
    }
}

#[async_trait]
impl PriceProvider for MyApiProvider {
    fn id(&self) -> ProviderId {
        ProviderId::MyApi
    }

    async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError> {
        // one HTTP call, parse, map to currency -> Quote. That is all.
        let body: MyApiResponse = http
            .get(format!("{}/rates", self.url))
            .send()
            .await?
            .json()
            .await?;
        Ok(body
            .rates
            .into_iter()
            .map(|(ccy, price)| (ccy, Quote::PerBtc(price)))
            .collect())
    }
}
```

**Step 2 — declare it.** Add a `ProviderId::MyApi` variant and
`pub mod myapi;` in `src/price/providers/mod.rs`.

**Step 3 — register it.** Add exactly one arm to the registry builder —
the *single* designated extension point in the codebase:

```rust
// src/price/mod.rs
fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Box<dyn PriceProvider> {
    match id {
        ProviderId::Yadio     => Box::new(yadio::YadioProvider::new(cfg)),
        ProviderId::CoinGecko => Box::new(coingecko::CoinGeckoProvider::new(cfg)),
        ProviderId::ElToque   => Box::new(eltoque::ElToqueProvider::new(cfg)),
        ProviderId::MyApi     => Box::new(myapi::MyApiProvider::new(cfg)), // <- the one line
    }
}
```

**Step 4 — configure it.** Add a `[price.providers.myapi]` block to
`settings.tpl.toml` with `enabled`, `url`, and any `token`/`api_key`.
If a secret is required, add it to the startup validation (§7) so an
enabled-but-unconfigured provider fails fast.

**Step 5 — test it.** Commit a captured real response as a fixture and
add one parse test (`fetch`-maps-fixture → expected quotes). The
aggregation needs **no** new tests — it is provider-agnostic and already
covered by Phase 0.

**What you never touch:** `aggregate.rs`, `store.rs`, the scheduler tick,
`get_bitcoin_price` / `get_market_quote`, or any order handler. That
invariant is what makes provider count a config concern, not an
engineering project — and it is locked down by principle §3.1 and the
acceptance tests of each provider phase (1, 2, 3), each of which adds a
provider **without** modifying the core.

## 6. Aggregation algorithm (normative)

### 6.1 Inputs

Per tick, for each healthy provider, a `ProviderQuotes` map (or an error →
the provider contributes nothing this tick).

### 6.2 Per-currency combine

Given the list `xs` of candidate per-BTC prices for a currency (after
PerBase resolution, §6.3):

```text
n = len(xs)
n == 0 -> no fresh value (fall through to last-known-good, §6.4)
n == 1 -> xs[0]
n == 2 -> mean(xs)
n >= 3 -> m   = median(xs)
          kept = [x in xs if |x - m| / m <= outlier_threshold_pct/100]
          result = mean(kept)          # `kept` always contains m, so non-empty
```

`outlier_threshold_pct` defaults to **5.0**. The median anchors the
"truth"; values too far from it are discarded before the mean, so one
corrupt/stale source cannot move the result while genuine small spreads
between honest sources are still averaged in.

### 6.3 PerBase resolution (fiat-cross sources)

A `Quote::PerBase { base, value }` for currency `C` resolves to a per-BTC
candidate **only if** the aggregate per-BTC price for `base` is available
this tick (from the direct quoters in step 1):

```text
candidate(C) = value × aggregate_per_btc(base)
```

Worked example (El Toque, base = USD):

```text
El Toque:  CUP per USD = 400      -> Quote::PerBase { base: "USD", value: 400 }
Aggregate: USD per BTC = 50_000   (from Yadio + CoinGecko)
=> CUP per BTC candidate = 400 × 50_000 = 20_000_000
```

If `base`'s per-BTC anchor is unavailable (all direct quoters down), the
PerBase quote is dropped for this tick and the currency falls back to
last-known-good. The dependency is documented so operators understand
El Toque's CUP/MLC need at least one direct USD source to be live.

> The El Toque adapter performs any internal cross math (e.g. deriving
> *MLC per USD* from its CUP-denominated payload) **inside**
> `providers/eltoque.rs`, emitting clean `PerBase { base: "USD", … }`
> quotes. The aggregator stays generic. See §11.3.

### 6.4 Staleness (last-known-good + TTL)

Each currency's stored `AggregatedPrice` carries `as_of` = the timestamp
of the last tick that produced a fresh aggregate for it.

- A tick that yields a fresh value overwrites the entry with `as_of = now`.
- A tick with zero contributors for a currency leaves the prior entry
  untouched (old `as_of`).
- `PriceManager::get_price(ccy)`:
  - entry missing → `Err(NoCurrency)`.
  - `now - as_of <= max_price_staleness_seconds``Ok(value)` (a `warn!`
    is logged once the value is older than one update interval).
  - else → `Err(PriceTooStale)` (§10.2). Market-priced create/take for
    that currency is refused with a clear message.

`max_price_staleness_seconds` defaults to **1800** (30 min) — long enough
to ride out short API outages, short enough that nobody trades on an
hours-old quote.

### 6.5 Per-provider health / circuit breaker

Per provider, track consecutive failures. After `provider_failure_threshold`
(default 3) consecutive failures, skip the provider for a cooldown that
backs off exponentially from `provider_failure_cooldown_seconds` (default
120) up to a cap (default 1800). A success resets the counter. This keeps
a hard-down API from slowing every tick (each poll is also bounded by
`provider_timeout_seconds`, default 10) and from spamming error logs.

### 6.6 Currency normalisation, scoping, and mid-market

These rules sit between the adapters and §6.2's combine, and are what make
heterogeneous providers safely comparable.

- **Code canonicalisation.** All currency codes are upper-cased before
  they reach the combine step (adapters do this; the read path upper-cases
  the requested code too). Without this, `currency-api`'s `"usd"` and
  Yadio's `"USD"` would form two separate, single-source aggregates.
- **Fiat allowlist.** Providers return junk for our purposes: `currency-api`
  ships **324** entries including crypto (`eth`, `bnb`, `ada`) and
  non-ISO codes. The aggregator restricts to the node's known fiat set
  (the currencies Mostro already validates for orders); everything else is
  dropped before aggregation and before the Nostr publish, keeping the
  store and the kind-30078 event lean.
- **Per-provider currency scoping (`only` / `except`).** Some providers
  report a currency on a **different market** than others, and averaging
  across markets is wrong — not merely noisy. The motivating case is
  **CUP**: `currency-api` reports CUP at the **official** rate (~24
  CUP/USD), while Yadio and El Toque track the **informal** rate (~400
  CUP/USD) — a ~16× gap. Config can restrict which currencies a provider
  may contribute (`except = ["CUP","MLC"]` on `currency-api`, or
  `only = ["CUP","MLC"]` on El Toque). Scoping is applied **before**
  combine, so a mis-marked source never enters the median at all.
  - The §6.2 outlier guard is the **safety net** for *accidental*
    divergence with ≥3 honest sources (the official-rate value is
    discarded as an outlier), but it does **not** help with only two
    sources (`mean` of official + informal = garbage). Scoping is the
    deterministic fix; the outlier guard backstops it. Note this refines
    the "no authoritative override" decision (§4): scoping restricts a
    source's *coverage*, it does not pick a winner among legitimate
    same-market contributors.
- **Mid-market only.** Adapters discard bid/ask; Mostro never applies an
  exchange spread (contrast BTCPay, §11.6). The order premium/fee is the
  only markup, applied downstream in `get_market_quote` / `get_fee`.

## 7. Configuration surface (final shape)

New `[price]` section. Missing section ≡ "Yadio only, today's behaviour"
(see §10.1 migration).

```toml
[price]
# How often to poll providers and recompute the aggregate.
update_interval_seconds = 300
# Serve a currency's last-known-good value up to this age; then refuse.
max_price_staleness_seconds = 1800
# Discard a source whose value deviates more than this % from the median
# (only applies with >= 3 sources for a currency).
outlier_threshold_pct = 5.0
# Per-provider request timeout.
provider_timeout_seconds = 10
# Circuit breaker.
provider_failure_threshold = 3
provider_failure_cooldown_seconds = 120
# Publish the aggregated rates to Nostr (kind 30078). Replaces
# publish_exchange_rates_to_nostr.
publish_to_nostr = true

[price.providers.yadio]
enabled = true
url = "https://api.yadio.io"

[price.providers.coingecko]
enabled = true
url = "https://api.coingecko.com/api/v3"
# api_key = "CG-xxxx"   # optional demo/pro key; raises rate limits

# Keyless, CDN-hosted, 300+ currencies incl. CUP (OFFICIAL rate) — so CUP
# is excluded to avoid mixing it with the informal-market sources (§6.6).
[price.providers.currency_api]
enabled = true
url = "https://currency-api.pages.dev/v1"
# Optional ordered mirrors, tried in sequence if `url` fails (§7).
fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"]
except = ["CUP", "MLC"]

# Keyless, 28 major fiats, no CUP/MLC.
[price.providers.blockchain]
enabled = true
url = "https://blockchain.info"

[price.providers.eltoque]
enabled = false          # opt-in: requires a token
url = "https://tasas.eltoque.com"
# token = "xxxx"         # REQUIRED when enabled; provider refuses to start otherwise
only = ["CUP", "MLC"]    # El Toque is only meaningful for these (§6.6)
```

- Each `[price.providers.<id>]` sub-table is deserialized into a generic
  `ProviderConfig { enabled, url, fallback_urls?, api_key?, token?, only?,
  except? }`. Adding a provider adds a sub-table; the loader maps known
  ids to their adapter (§5.4).
  - `fallback_urls`: ordered mirrors tried when `url` fails this tick
    (e.g. `currency-api`'s jsdelivr mirror) — provider-level resilience
    on top of the multi-provider resilience.
  - `only` / `except`: per-provider currency allow/deny applied before
    aggregation (§6.6). `only` and `except` are mutually exclusive.
- Validation at startup: an enabled provider missing a required secret
  (El Toque without `token`) fails fast with a descriptive error rather
  than silently producing no quotes; `only``except` set on the same
  provider is rejected.

## 8. Phase overview

| Phase | PR scope | Depends on | Status |
|------:|----------|------------|--------|
| 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 | done (PR #778) |
| 4 | Unify `get_market_quote` onto the cache; staleness TTL enforcement (`PriceTooStale`) at create/take | 2 | done |
| 5 | Nostr aggregated publishing + token/paid-provider support polish + info-event exposure + retire `bitcoin_price.rs` + ops docs | 3, 4 | pending |

Phases 3 and 4 both depend on Phase 2 and can land in either order.

---

## 9. Phase details

### Phase 0 — Foundation (pure, no wiring)

**Scope**
- `src/price/provider.rs`: `PriceProvider` trait, `Quote`, `ProviderId`,
  `ProviderError`, `ProviderConfig`, health/circuit-breaker state type.
- `src/price/aggregate.rs`: pure functions —
  `combine(xs, outlier_pct) -> Option<f64>` (§6.2),
  `resolve_per_base(quotes, anchors) -> per_currency_candidates` (§6.3),
  `aggregate_tick(provider_results, cfg) -> HashMap<String, f64>` (steps
  1–3 of §5.3). No I/O, no globals.
- `src/price/store.rs`: `AggregatedPrice { value, as_of, source_count }`,
  the `RwLock<HashMap<String, AggregatedPrice>>` store, and
  staleness-checked `get` (§6.4).
- `src/price/config.rs`: `PriceSettings` + `ProviderConfig` serde types
  with defaults from §7. Add `Option<PriceSettings>` to `Settings` with a
  `Settings::get_price()` accessor.

**Non-goals:** no HTTP, no scheduler change, no consumer change.

**Acceptance / tests**
- `combine`: 0/1/2/≥3 sources; outlier discarded at the boundary; all-equal;
  NaN/inf/≤0 rejected as inputs.
- `resolve_per_base`: resolves with anchor present; drops when anchor
  missing; the El Toque worked example (§6.3).
- `aggregate_tick`: union of partial-coverage providers; a provider error
  contributes nothing; CUP from {Yadio,ElToque}, EUR from
  {Yadio,CoinGecko,ElToque-absent}.
- staleness `get`: fresh / within-TTL / past-TTL / missing.

### Phase 1 — Yadio provider + registry (single-source parity)

**Scope**
- `src/price/providers/yadio.rs`: `YadioProvider` implementing
  `PriceProvider` via `GET {url}/exrates/BTC`, mapping the
  `{ "BTC": { ccy: price } }` body to `PerBtc` quotes.
- `src/price/mod.rs`: `PriceManager` building the provider registry from
  `[price]`, a `update_all(&self)` tick (poll → aggregate → store), and
  `get_price(ccy)` (staleness check **logged but not enforced** yet — see
  Phase 4, to preserve current "never refuse" behaviour during rollout).
- Scheduler: `job_update_bitcoin_prices` calls `PriceManager::update_all`.
- `get_bitcoin_price` (`util.rs`) reads `PriceManager` instead of
  `BitcoinPriceManager`. Keep a `BitcoinPriceManager::get_price` shim
  delegating to `PriceManager`.
- Nostr publishing keeps working unchanged (still effectively one source);
  `source` tag becomes the contributing-source list (here, `["yadio"]`).

**Acceptance / tests**
- With only Yadio enabled, `get_bitcoin_price` returns the same values as
  today for the captured sample payload.
- Yadio down for a tick → store keeps prior values; no panic.
- `enabled = false` on Yadio with no other provider → empty store; reads
  return `NoCurrency` (logged), matching "no data yet" today.

### Phase 2 — Direct backup quoters + multi-source aggregation

Adds the keyless direct backups so the system is genuinely multi-source.
Each is a `PerBtc` adapter (§5.4) — they exercise the same contract, so
they can land in one PR or be split per provider.

**Scope**
- `src/price/providers/coingecko.rs`: via
  `GET {url}/simple/price?ids=bitcoin&vs_currencies=<list>`, `PerBtc`,
  optional `api_key`. No CUP/MLC.
- `src/price/providers/currency_api.rs`: via
  `GET {url}/currencies/btc.min.json`, lowercase codes upper-cased,
  `PerBtc`. Wide coverage incl. CUP at the **official** rate → ships
  `except = ["CUP","MLC"]` (§6.6). Uses `fallback_urls` (jsdelivr mirror).
- `src/price/providers/blockchain.rs`: via
  `GET {url}/ticker`, takes `last` (mid-market), `PerBtc`. 28 majors.
- Wire the circuit breaker + per-provider timeout (§6.5) into
  `update_all` (parallel `fetch` with `tokio`).
- Implement the §6.6 pipeline glue: code upper-casing, fiat allowlist,
  per-provider `only`/`except` scoping.

**Acceptance / tests**
- EUR/USD/JPY aggregate = median+outlier across all live direct quoters.
- Lowercase `currency-api` codes combine with uppercase Yadio codes (the
  normalisation test — would silently fail without §6.6).
- `currency-api`'s official CUP is **scoped out**, so it never enters the
  CUP aggregate; with a synthetic 3rd informal source it would also be
  rejected by the outlier guard (both layers tested).
- non-fiat codes (`eth`, `bnb`) from `currency-api` are dropped by the
  allowlist.
- One provider returns a wild outlier with ≥3 sources → discarded.
- A provider down → currencies fall back to the remaining sources; a
  provider's `fallback_urls` is tried before the provider is marked failed.
- Circuit breaker opens after N failures and closes after cooldown.

### Phase 3 — El Toque provider (fiat-cross CUP/MLC)

**Scope**
- `src/price/providers/eltoque.rs`: `ElToqueProvider` via the El Toque
  tasas API (Bearer `token`), emitting `PerBase { base: "USD", value }`
  for CUP and MLC (internal cross math per §11.3).
- No aggregation-core change — Phase 0's PerBase resolution already
  handles it.

**Acceptance / tests**
- CUP aggregate = combine(Yadio CUP/BTC, ElToque-resolved CUP/BTC).
- El Toque up, Yadio CUP down → CUP from El Toque only.
- Yadio up, El Toque down → CUP from Yadio only.
- **Anchor dependency:** all direct USD quoters down → El Toque CUP/MLC
  drop to last-known-good (resolution impossible without a USD anchor).
- El Toque enabled without `token` → startup error.

### Phase 4 — Unify the live path + enforce staleness

**Scope**
- Rewrite `get_market_quote` (`util.rs`) to compute
  `sats = (fiat_amount / aggregate_btc_price(ccy)) × 1e8` from the cache,
  applying premium as today. Remove `retries_yadio_request` and the
  per-take Yadio `/convert` call.
- Turn on staleness enforcement (§6.4): `get_price` / `get_market_quote`
  return `PriceTooStale` past the TTL. Order create (`app/order.rs`) and
  market-priced takes surface it as a clean `CantDo`/error to the user
  instead of pricing on stale data.

**Acceptance / tests**
- `get_market_quote` parity vs the old `/convert` math for a known price.
- Past-TTL currency → create/take refused with `PriceTooStale`; other
  currencies unaffected.
- No HTTP call happens during a take (cache read only).

> **Phase 4 shipped status.** `get_market_quote` (`util.rs`) is now a
> synchronous cache read — `sats = (fiat_amount / get_price(ccy)) × 1e8`
> with the premium applied — and the live Yadio path
> (`retries_yadio_request`, `yadio_base_url`, the `/convert` call, the
> `Yadio` response model) is deleted. With the live path gone, the legacy
> `[mostro].bitcoin_price_api_url` now feeds **only** legacy synthesis
> (§10.1).
>
> Staleness is enforced in `PriceManager::get_price`: a value past
> `max_price_staleness_seconds` returns
> `ServiceError::PriceTooStale` (still warning once on the transition).
> Order create (`app/order.rs`) and market-priced takes
> (`app/take_buy.rs`, `app/take_sell.rs`) map that onto a user-facing
> `CantDoReason::PriceTooStale`. The dedicated error variants landed in
> **mostro-core 0.13.1** (MostroP2P/mostro-core#153), replacing the
> interim `NoAPIResponse` reuse described in §10.2.
>
> **Known gap — bonded takes (follow-up).** With taker bonds enabled,
> the staleness check runs at take-time, but the resulting quote is
> snapshotted into the bond's `taker_*` columns and copied verbatim onto
> the order when the bond locks (`promote_taker_context_to_order`), with
> no re-pricing or staleness re-check. A taker who pays the bond after
> the TTL elapses therefore completes on a quote the direct path would
> refuse. Until the bond flow revalidates (or expires) the snapshot at
> lock time, the Phase 4 guarantee applies to the **direct (bond-less)
> path only**.
>
> **Legacy configs.** Enforcement does **not** require a `[price]`
> block: when the section is absent, §10.1 synthesises one (single
> Yadio provider) and staleness applies with the **default TTL of
> 1800 s**. Tuning `max_price_staleness_seconds` does require adding a
> `[price]` block, since the legacy `[mostro]` keys carry no staleness
> setting.

### Phase 5 — Nostr publishing, paid providers, exposure, cleanup

**Scope**
- Publish the **aggregated** map to Nostr (kind 30078). `source` tag
  carries the contributing provider ids; optionally a per-currency
  source-count tag. Update `docs/NOSTR_EXCHANGE_RATES.md`.
- Reference implementation + docs for a **token/paid** provider config
  (the `ProviderConfig.token`/`api_key` plumbing already exists from
  Phase 0; this validates the secret-handling end to end).
- Optionally surface bond-style policy on the Mostro info event: which
  providers are enabled (ids only, never secrets).
- Retire `src/bitcoin_price.rs` once all consumers read `PriceManager`.
- Operator docs: `docs/LIGHTNING_OPS.md` / a price-ops runbook (reading
  health logs, adding a provider, rotating a token).

---

## 10. Cross-cutting concerns

### 10.1 Backward compatibility

- **Config migration.** When `[price]` is absent, synthesise a
  default config: a single `yadio` provider using the legacy
  `bitcoin_price_api_url`, with `update_interval_seconds` =
  `exchange_rates_update_interval_seconds` and `publish_to_nostr` =
  `publish_exchange_rates_to_nostr`. So existing `settings.toml` files
  keep working byte-for-byte; the legacy keys are honoured and marked
  deprecated in the template. Default *new* config also enables the
  keyless backups — CoinGecko, `currency-api`, and Blockchain.com — so a
  fresh node is multi-source out of the box without any signup. El Toque
  stays opt-in (needs a token). With these four, USD/EUR/major pairs have
  3–4 sources (full median + outlier protection), and CUP/MLC have Yadio
  (informal) plus optionally El Toque, with `currency-api`'s official-rate
  CUP scoped out (§6.6).
- **Consumer surface.** `get_bitcoin_price` keeps its signature;
  `BitcoinPriceManager::get_price` becomes a shim until Phase 5.
- **Behaviour during rollout.** Staleness was logged-only through Phases
  1–3 (never refusing an order that would have priced today); Phase 4
  turns enforcement on (§6.4), so a market-priced create/take is refused
  with `PriceTooStale` once the rate ages past the TTL.

### 10.2 mostro-core changes

- A new error for refused stale prices was needed:
  `ServiceError::PriceTooStale` (internal) and `CantDoReason::PriceTooStale`
  (user-facing) in `mostro-core`. **Shipped in mostro-core 0.13.1**
  (MostroP2P/mostro-core#153) — a serde-additive change. Phase 4 consumes
  these directly; the interim `NoAPIResponse` reuse mentioned here is no
  longer used.

### 10.3 Security

- Tokens/keys come only from config and live only in the provider adapter.
  They must never appear in logs (`tracing`), the Nostr event, the info
  event, or error messages. A redaction test asserts a provider's `Debug`
  and any logged error omit the secret.

### 10.4 Observability

- `tracing` per tick: per-provider outcome (ok/{n currencies} | skipped:
  cooldown | error), and per-currency `source_count`. A currency dropping
  to a single source, or to last-known-good, logs at `warn`.
- No Prometheus wiring until real traffic justifies it.

### 10.5 Testing discipline

- Aggregation (`aggregate.rs`) is pure → exhaustive unit tests, the
  numeric heart of the feature.
- Provider adapters tested against **captured real payloads** committed as
  fixtures (so a provider changing its JSON shape is caught), parsing
  offline — no network in tests.
- A `MockProvider` (configurable quotes / forced errors / latency) drives
  end-to-end aggregation, circuit-breaker, and staleness tests without
  HTTP.

---

## 11. Appendix — provider notes

### 11.1 Yadio (direct, 120+ currencies incl. CUP/MLC)
- `GET /exrates/BTC``{ "BTC": { "USD": 50000, "CUP": 20000000, … } }`.
  Each value is fiat-per-BTC → `Quote::PerBtc`.
- The widest source; the practical anchor for USD/BTC when CoinGecko is
  down.

### 11.2 CoinGecko (direct, many currencies, NO CUP/MLC)
- `GET /simple/price?ids=bitcoin&vs_currencies=usd,eur,…`  `{ "bitcoin": { "usd": 50000, … } }``Quote::PerBtc`.
- Keyless tier is rate-limited; optional demo/pro `api_key` raises limits.
- Does not list CUP/MLC, so it only ever contributes to the currencies it
  reports — exactly the desired behaviour, no special casing.

### 11.3 El Toque (fiat-cross, CUP/MLC only) — ⚠ confirm

El Toque publishes the **informal Cuban market rate** as **CUP per
foreign unit** (e.g. CUP per USD, CUP per EUR, CUP per MLC) — it is **not
a BTC price source**. Therefore:

- Its quotes are `Quote::PerBase { base: "USD", value }`, resolved against
  the aggregated USD/BTC anchor (§6.3). CUP/MLC require **at least one
  live direct USD source** (Yadio or CoinGecko).
- The adapter derives `MLC per USD` from El Toque's CUP-denominated
  payload internally:
  `MLC_per_USD = cup_per_usd / cup_per_mlc`, then emits
  `PerBase { base: "USD", value: MLC_per_USD }` for MLC and
  `PerBase { base: "USD", value: cup_per_usd }` for CUP.
- Requires a Bearer **token** (free registration); enabled-without-token
  is a startup error.

**To confirm with the maintainer:**
1. Does the El Toque plan you intend to use expose CUP, MLC, **and** a USD
   (and/or EUR) cross in one call? The adapter math above assumes
   `cup_per_usd` and `cup_per_mlc` are both present.
2. Should EUR be a second anchor fallback for CUP when USD/BTC is
   momentarily unavailable but EUR/BTC is live? (Cheap to add; keeps
   CUP/MLC alive in more outage shapes.)
3. Confirm El Toque's CUP and Yadio's CUP track the **same** (informal)
   market, so averaging them is apples-to-apples. **Concrete evidence
   this matters:** `currency-api` (§11.5) reports CUP at the *official*
   rate (~24 CUP/USD) vs the informal ~400 CUP/USD of Yadio/El Toque — a
   ~16× gap. That is exactly why §6.6 adds per-provider currency scoping
   (`currency-api` ships with `except = ["CUP","MLC"]`). Please confirm
   Yadio's CUP is the informal rate (it has been historically); if Yadio
   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" }, … }`.
  Uppercase codes. The adapter takes **`last`** (mid-market) → `PerBtc`,
  discarding `buy`/`sell` (§6.6 mid-market rule).
- Keyless. Only ~28 major currencies, so it is a redundancy anchor for
  USD/EUR/GBP/JPY/etc., not a long-tail source.

### 11.5 currency-api / fawazahmed0 (direct, 300+ currencies incl. CUP)
- `GET {url}/currencies/btc.min.json`  `{ "date":"…", "btc": { "usd":77817.3, "cup":1867119.0, … } }`.
  **Lowercase** codes (adapter upper-cases them, §6.6); values are
  fiat-per-BTC → `PerBtc`.
- Keyless and **CDN-hosted** (Cloudflare Pages, with a jsdelivr mirror —
  configure both via `fallback_urls`), so it is one of the most reliable
  backups available; an excellent default.
- **324 entries including crypto** (`eth`, `bnb`, …) → relies on the §6.6
  fiat allowlist to drop non-fiat codes.
- **CUP is the OFFICIAL rate** (~24 CUP/USD), a different market from the
  informal sources → shipped with `except = ["CUP","MLC"]` (§6.6). It
  *does* strengthen the long tail of legitimately-single-market
  currencies that Yadio also lists.

### 11.6 Prior art — BTCPayServer (design rationale)

BTCPayServer solves the same problem; contrasting choices clarify ours:

- **Provider abstraction.** BTCPay's `IRateProvider` + `RateProviderFactory`
  is the same shape as our `PriceProvider` trait + registry (§5) — strong
  validation that the abstraction is right.
- **Background fetch + cache.** BTCPay wraps providers in a
  `BackgroundFetcherRateProvider` refreshing ~every minute, decoupling
  fetch from use. That is exactly our scheduler-poll → store → read model
  with a staleness TTL (§6.4).
- **Rate-rule DSL vs fixed aggregation.** BTCPay exposes a scripting DSL
  (`BTC_USD = kraken(BTC_USD) ?? coinbase(BTC_USD)`) for per-store fallback
  chains. We deliberately choose **fixed robust aggregation (median +
  outlier) + declarative config** instead: simpler to audit, no operator
  scripting, and our use case (mid-market BTC/fiat for order pricing)
  needs resilience and correctness, not arbitrary per-pair logic. A DSL
  remains a possible future direction if real demand appears — noted as a
  non-goal for now.
- **Cross rates.** BTCPay derives crosses via explicit rules, not
  automatic triangulation. We do a **single, targeted** one-hop
  resolution for fiat-cross providers (`PerBase`, §6.3) — enough for
  El Toque — and treat general N-hop triangulation as a non-goal.
- **Bid/ask + spread.** BTCPay models bid/ask and lets stores add a
  spread. Mostro prices at **mid-market with no spread** (§6.6); the order
  premium/fee is the only markup. We call this out so the spread concept
  is not reintroduced by accident when porting a provider idea from
  BTCPay.

---

## 12. Tracking

Each phase ships as a separate PR linking this document. The PR
description states: which phase, which providers/config it touches, and
the test evidence (captured payloads + aggregation unit tests; a manual
"kill one API, watch the others carry the currency" check from Phase 2
on). When the full plan has landed, this spec stays in `docs/` as the
feature's reference.