Skip to content

feat(price): Phase 2 — direct backup quoters + multi-source aggregation#773

Merged
grunch merged 2 commits into
mainfrom
feat/price-phase2-backup-quoters
Jun 16, 2026
Merged

feat(price): Phase 2 — direct backup quoters + multi-source aggregation#773
grunch merged 2 commits into
mainfrom
feat/price-phase2-backup-quoters

Conversation

@grunch

@grunch grunch commented Jun 11, 2026

Copy link
Copy Markdown
Member

Phase 2 of docs/PRICE_PROVIDERS.md — direct backup quoters + multi-source aggregation

Phase (spec §8): 2 — depends on Phase 1 (#753, merged). With this PR the price system becomes genuinely multi-source: three keyless direct quoters join Yadio, providers are polled concurrently behind a circuit breaker, and the §6.6 normalisation pipeline (fiat allowlist + per-provider scoping) guards the aggregate.

Providers added (each: one adapter file + one registry arm + one config block + one captured fixture, per §5.4)

Provider Endpoint Notes
coingecko GET {url}/simple/price?ids=bitcoin&vs_currencies=… optional api_key (demo/pro header picked from host), redacted from Debug/logs (§10.3)
currency_api GET {url}/currencies/btc.min.json lowercase codes upper-cased (§6.6); fallback_urls mirrors tried in order (§7)
blockchain GET {url}/ticker takes last (mid-market) only; buy/sell not even deserialised (§6.6/§11.6)

Core changes

  • Concurrent polling (§5.3): update_all polls all breaker-available providers in parallel; tick wall-clock = slowest single provider, each fetch bounded by provider_timeout_seconds.
  • Circuit breaker wired (§6.5): Phase 0's ProviderHealth now gates polling. After provider_failure_threshold consecutive failures a provider is skipped (TickReport.skipped, logged as skipped: cooldown) for an exponentially-backed-off cooldown capped at 1800 s; one success resets it.
  • Fiat allowlist (§6.6, new src/price/fiat.rs): ISO-4217 active codes + the non-ISO fiat Yadio really trades (IRT, GGP/IMP/JEP, MLC, XCG). Validated against the live Yadio feed: drops exactly BTC/XAU/XAG/XPT and nothing else. VEF deliberately excluded (pre-redenomination unit; mixing it with VES would price orders with a garbage rate).
  • Official-CUP hazard is real and pinned in a fixture: the captured currency-api payload shows CUP at ~26 CUP/USD (official) vs Yadio's ~400 (informal) — the shipped config scopes it out with except = ["CUP", "MLC"], and a test proves scoping (not the outlier guard) is what saves the 2-source case.

Test evidence (§12)

  • Captured live payloads (2026-06-11) committed as fixtures for all three adapters; parse tests offline.
  • §9 Phase 2 acceptance tests: median+outlier across 4 providers · lowercase/uppercase combine · official CUP scoped out · eth/bnb dropped by allowlist · provider-down fallback · fallback_urls tried before failing (local-axum integration test) · breaker opens after N failures / skips without polling / resets on success.
  • Full suite: 431 passed, 0 failed; cargo fmt + clippy clean.

🧪 Manual testing — step by step

Prereqs: a working mostrod dev setup (LND not required for price checks; the daemon will log price ticks regardless of trading activity).

1. Multi-source happy path

  1. In your settings.toml, add (or uncomment from settings.tpl.toml) the [price] block with all four providers enabled. For a fast feedback loop set update_interval_seconds = 60:
    [price]
    update_interval_seconds = 60
    
    [price.providers.yadio]
    enabled = true
    url = "https://api.yadio.io"
    
    [price.providers.coingecko]
    enabled = true
    url = "https://api.coingecko.com/api/v3"
    
    [price.providers.currency_api]
    enabled = true
    url = "https://currency-api.pages.dev/v1"
    fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"]
    except = ["CUP", "MLC"]
    
    [price.providers.blockchain]
    enabled = true
    url = "https://blockchain.info"
  2. Run RUST_LOG=mostrod=info cargo run -- -d <your-data-dir> and watch one tick. Expected log lines:
    price: yadio ok (124 currencies)
    price: coingecko ok (44 currencies)
    price: currency_api ok (~150 currencies)
    price: blockchain ok (~30 currencies)
    price: published exchange rates to Nostr (… currencies)
    
    ✔️ All four report ok; the published currency count is larger than any single provider (union of coverage).
  3. Check the kind-30078 event on your relay (e.g. with nak req -k 30078 -a <your mostro pubkey> <relay>): the source tag should read blockchain,coingecko,currency_api,yadio (alphabetical), and the BTC map must not contain ETH, BNB, XAU or BTC itself (allowlist proof).

2. Kill one API, watch the others carry the currency (spec §12 check)

  1. Break CoinGecko only — point it at a dead URL:
    [price.providers.coingecko]
    enabled = true
    url = "https://127.0.0.1:1"
  2. Restart and watch ~4 ticks. Expected:
    • Tick 1–3: price: coingecko error: … while the other three still report ok — USD/EUR keep updating (last-known-good never needed).
    • Tick 4 (after provider_failure_threshold = 3 failures): price: coingecko skipped: cooldown (circuit breaker open) — the dead API is no longer even polled, and the tick is not slowed down by its timeout.
    • The source tag drops coingecko but keeps the other three.
  3. Restore the real URL, restart: first tick logs coingecko ok again (breaker resets on success).

3. Mirror fallback (fallback_urls)

  1. Sabotage currency-api's primary but keep the jsdelivr mirror:
    [price.providers.currency_api]
    enabled = true
    url = "https://127.0.0.1:1"
    fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"]
    except = ["CUP", "MLC"]
  2. Expected per tick: one price: currency_api mirror https://127.0.0.1:1 failed: … warn, followed by price: currency_api ok (…) — the mirror carried the fetch, the provider is not marked failed, breaker stays closed.

4. CUP stays on the informal market

  1. With all four providers enabled (config from step 1), take note of the published CUP value, then compute CUP/BTC ÷ USD/BTC. Expected: ~400 CUP/USD (informal market — Yadio only).
  2. Now remove except = ["CUP", "MLC"] from [price.providers.currency_api] and restart. The CUP/USD ratio collapses to roughly half (~213 CUP/USD) because the official ~26 rate is now averaged in — this is exactly the corruption the shipped except prevents. Put the except back.

5. Startup validation still guards Phase 3

  1. Enable the not-yet-implemented provider:
    [price.providers.eltoque]
    enabled = true
    url = "https://tasas.eltoque.com"
    token = "x"
  2. Expected: mostrod refuses to start with price: provider 'eltoque' is configured (enabled) but not yet implemented in this release…. Remove the block.

6. Legacy config untouched (regression)

  1. Delete/comment the whole [price] section so only the legacy [mostro].bitcoin_price_api_url remains.
  2. Expected: daemon starts, single-source Yadio ticks exactly as in Phase 1 (price: yadio ok …), orders price normally — the migration path is untouched by this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added three new price providers: Blockchain.com, CoinGecko, and CurrencyApi.
    • Added fiat currency validation/allowlist for ISO-4217 codes.
  • Improvements

    • Concurrent multi-provider polling with per-provider timeouts, circuit-breaker skipping, scoped filtering, and aggregation resilience.
  • Documentation

    • Updated provider phase progress and expanded configuration examples.
  • Chores

    • Added futures dependency.
  • Tests

    • New/updated price fixtures and expanded unit/integration tests.

Implements Phase 2 of docs/PRICE_PROVIDERS.md: the system becomes
genuinely multi-source. Three keyless direct (PerBtc) adapters join
Yadio, each fixture-tested against a captured live payload (§10.5):

- coingecko: GET /simple/price over a baked fiat vs_currencies list;
  optional demo/pro api_key (header picked from the host), redacted
  from Debug/logs per §10.3.
- currency_api: GET /currencies/btc.min.json; lowercase codes
  upper-cased (§6.6); implements `fallback_urls` — mirrors tried in
  order, the provider only fails when every URL fails (§7). Fallback
  behaviour covered by a local-axum integration test.
- blockchain: GET /ticker taking `last` (mid-market) only; buy/sell
  are not even deserialised (§6.6/§11.6).

Manager (spec §5.3, §6.5, §6.6):
- Providers are polled concurrently (join_all), each fetch bounded by
  provider_timeout_seconds — tick wall-clock = slowest provider, not
  the sum.
- Circuit breaker wired: Phase 0's ProviderHealth now gates polling;
  cooldown-skipped providers land in the new TickReport.skipped
  (neither success nor failure). Backoff cap fixed at 1800s (§6.5).
- §6.6 fiat allowlist (new price::fiat): ISO-4217 active codes plus
  the non-ISO fiat Yadio really trades (IRT, GGP/IMP/JEP, MLC, XCG).
  Against the live Yadio feed this drops exactly BTC/XAU/XAG/XPT.
  VEF is deliberately excluded (pre-redenomination unit vs VES); the
  CoinGecko request list omits it for the same reason.

Phase 2 acceptance tests (§9): median+outlier across 4 providers,
lowercase/uppercase combine, official-CUP scoped out by `except`
(2-source case the outlier guard can't save), non-fiat dropped by
allowlist, provider-down fallback, breaker open-skip and
reset-after-success. 431 tests green.

Config: settings.tpl.toml gains the three §7 provider blocks
(currency_api ships except = ["CUP", "MLC"]). El Toque remains
rejected at startup until Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 280d82e1-b9b2-457d-b8aa-edbd4459ee5d

📥 Commits

Reviewing files that changed from the base of the PR and between 4efc1e9 and 034aeed.

📒 Files selected for processing (2)
  • src/price/manager.rs
  • src/price/providers/currency_api.rs

Walkthrough

This PR implements Phase 1 of a multi-source Bitcoin/fiat pricing architecture by introducing per-provider circuit breaker cooldown tracking, concurrent polling with timeouts, fiat allowlist validation, and three new price provider adapters (CoinGecko, CurrencyApi, Blockchain), while enriching tick reporting with skipped provider tracking.

Changes

Multi-source Price Provider Architecture

Layer / File(s) Summary
Fiat allowlist foundation
src/price/fiat.rs
Defines a sorted ISO-4217 fiat allowlist constant and public is_known_fiat() helper using binary search, with tests verifying allowlist invariants, case-insensitive code matching, and live Yadio currency validation.
Public API exports and module wiring
src/price/mod.rs, src/price/providers/mod.rs
Exports the new fiat submodule and exposes blockchain, coingecko, and currency_api provider modules to the public API.
New provider adapters
src/price/providers/coingecko.rs, src/price/providers/currency_api.rs, src/price/providers/blockchain.rs, tests/fixtures/price/*
Implements three provider adapters following the PriceProvider trait: JSON parsing, currency-code normalization (uppercase), finite-positive value filtering, error wrapping, sequential mirror fallbacks for CurrencyApi, and redacted API-key handling for CoinGecko; includes unit and integration tests plus fixtures.
Manager orchestration with circuit breaker and filtering
src/price/manager.rs, Cargo.toml
Extends PriceManager with per-provider ProviderHealth (mutex), refactors update_all to poll providers concurrently with per-provider outer timeouts, records TickReport.skipped, applies KNOWN_FIAT filtering and per-provider scoping before aggregation, expands build_provider to instantiate the three adapters, adds poll_budget, and introduces futures dependency; extensive tests added.
Configuration templates and phase documentation
settings.tpl.toml, docs/PRICE_PROVIDERS.md
Adds commented provider configuration examples and updates phase status table (Phase 0 & 1 → done, Phase 2 → in review).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • MostroP2P/mostro#753: Both PRs modify PriceManager wiring and tick/update logic; this PR extends that subsystem with allowlist, circuit breaker tracking, and additional providers.
  • MostroP2P/mostro#745: Related spec/doc changes; this PR updates PRICE_PROVIDERS.md to reflect phase progress and implements the described multi-source architecture.
  • MostroP2P/mostro#747: Extends Phase-0 price foundation and module API; this PR builds on that by adding fiat allowlist and provider adapters.

Suggested reviewers

  • BraCR10
  • Catrya

Poem

A rabbit hops through price providers fair,
Coins and APIs caught in the air,
It filters fiat, skips breakers that pry,
Polls in parallel beneath the sky,
Hops off with a stitched TickReport—bye-bye! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically describes the main change: implementing Phase 2 with direct backup quoters and multi-source aggregation, which aligns with the substantial changes across price providers, manager concurrency, circuit breakers, and fiat allowlist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/price-phase2-backup-quoters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4efc1e915c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/price/manager.rs Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/price/manager.rs (1)

190-241: Add tracing spans around concurrent provider polling in PriceManager::update_all

  • The concurrent path logs per-provider outcomes with info!/warn!/debug!, but there’s no tracing span context (span!/info_span!/#[instrument]) around the join_all polling, making interleaving harder to correlate.
  • Add a tick-level span plus per-provider child spans (keyed by ProviderId) around the fetch/timeout so successes, errors, and timeouts are grouped cleanly.
🤖 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/price/manager.rs` around lines 190 - 241, Add a tick-level tracing span
around the whole polling block in PriceManager::update_all (create an info_span
like info_span!("price_tick", tick = %now) or similar) and instrument the
concurrent futures with per-provider child spans keyed by ProviderId inside the
join_all map (create a per-provider span e.g. info_span!("provider_fetch",
provider_id = %p.id) and use tracing::Instrument to .instrument that async
closure or the fetched future). Ensure you import tracing::Instrument and use
the span so the existing info!/warn! logs inside the match are correlated to the
per-provider child span; keep the timeout wrapping and outcome handling logic
unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/price/manager.rs`:
- Line 178: The timestamp `now` is captured too early (let it not be reused
after network polling); update the failure path so that `record_failure` gets a
fresh timestamp at the moment of failure rather than the earlier `now`
variable—either call Utc::now().timestamp() immediately before invoking
`record_failure` or change `record_failure` (and its callers) to compute the
current time inside itself; ensure references to `provider_timeout_seconds`,
`now`, and the `record_failure` invocation in `manager.rs` are updated so
backoff uses the real failure-time timestamp.

---

Nitpick comments:
In `@src/price/manager.rs`:
- Around line 190-241: Add a tick-level tracing span around the whole polling
block in PriceManager::update_all (create an info_span like
info_span!("price_tick", tick = %now) or similar) and instrument the concurrent
futures with per-provider child spans keyed by ProviderId inside the join_all
map (create a per-provider span e.g. info_span!("provider_fetch", provider_id =
%p.id) and use tracing::Instrument to .instrument that async closure or the
fetched future). Ensure you import tracing::Instrument and use the span so the
existing info!/warn! logs inside the match are correlated to the per-provider
child span; keep the timeout wrapping and outcome handling logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dd14089c-e09e-4e3e-b4a4-bf0d992a5e0b

📥 Commits

Reviewing files that changed from the base of the PR and between c7db272 and 4efc1e9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • Cargo.toml
  • docs/PRICE_PROVIDERS.md
  • settings.tpl.toml
  • src/price/fiat.rs
  • src/price/manager.rs
  • src/price/mod.rs
  • src/price/providers/blockchain.rs
  • src/price/providers/coingecko.rs
  • src/price/providers/currency_api.rs
  • src/price/providers/mod.rs
  • tests/fixtures/price/blockchain_ticker.json
  • tests/fixtures/price/coingecko_simple_price.json
  • tests/fixtures/price/currency_api_btc.json

Comment thread src/price/manager.rs
… breaker timestamps

Address PR #773 review findings:

- Codex P2 (valid): the outer per-provider timeout equalled the reqwest
  per-request timeout, so a *hanging* currency_api primary consumed the
  whole budget and the fallback_urls were dead code in exactly the hung
  case (they only worked for fast failures like connection-refused).
  New `poll_budget(id)` sizes the outer tokio timeout to
  provider_timeout_seconds × (1 + fallback_urls) + 1s slack; the
  per-attempt bound remains the shared reqwest client's request timeout.
  Adds a manager unit test for the scaling and an adapter-level axum
  test proving a hung primary is cut at the request timeout and the
  mirror still answers.

- CodeRabbit Major (valid, minor impact): record_failure reused the
  pre-poll `now`, so breaker cooldowns were born already aged by up to
  a full poll budget. Failures are now stamped after polling completes.

- CodeRabbit nit (declined): tracing spans around the polling — the
  codebase uses zero spans anywhere; per-provider outcomes already log
  the provider id inline, so spans here would be inconsistent house
  style for little correlation gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@grunch

grunch commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mostronatorcoder mostronatorcoder Bot left a comment

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.

Strict review: requesting changes.

  1. src/price/fiat.rs includes XCG in the fiat allowlist and in the "live Yadio codes survive" regression test. XCG is not an ISO-4217 fiat code and it is not documented anywhere in the spec as an intended exception, unlike IRT, GGP, IMP, JEP, or MLC. Because Phase 2 now treats this allowlist as the gate before aggregation and Nostr publish, adding an undocumented/non-standard code here silently expands supported currencies and provenance surface. If XCG is actually intentional, it needs spec/docs justification and provider-specific rationale; otherwise it should be removed from both the allowlist and the regression fixture expectation.

  2. I also verified the previously raised fallback-timeout issue and the stale timestamp issue are addressed in the current head: poll_budget() now covers the full mirror sequence, and breaker failures are stamped with a fresh post-poll timestamp.

I am not approving with the current XCG allowlist entry unresolved.

@arkanoider arkanoider left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I tested with different scenarios:

  • more than 1 provider working
  • 2 working and one fake
  • closing internet connection during retries

all seems good!

tACK!

@codaMW codaMW left a comment

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.

Tested on regtest (no LND needed for price ticks, as noted). tACK with evidence and I think the XCG hold can be resolved.

What I ran:

  • cargo test --bin mostrod fiat all price/fiat tests pass, including live_yadio_codes_survive_except_non_fiat (the one that asserts the captured Yadio feed survives the allowlist except BTC/XAU/XAG/XPT).
  • Full suite: 432 pass. The single failure is test_lnurl_validation_with_test_server (AddrInUse / port bind) it's in src/lightning/invoice.rs, untouched by this PR, and fails identically on upstream/main, so it's a pre-existing local-env flake, not introduced here.
  • Live multi-source tick (all four providers enabled): one steady-state tick logged
    price: coingecko ok (44 currencies)
    price: blockchain ok (29 currencies)
    price: currency_api ok (342 currencies)
    price: yadio ok (127 currencies)
    price: published exchange rates to Nostr (161 currencies)
    Published count (161) is larger than any single provider the union-of-coverage aggregation works as specified.
  • Legacy path (no [price] block, just bitcoin_price_api_url) also still ticks single-source Yadio unchanged the migration path is intact.

On the XCG hold:
I think XCG actually belongs in the allowlist, and removing it would regress a live currency. XCG is a standard ISO 4217 code it was assigned by ISO 4217 Amendment 176 (numeric 532) for the Caribbean guilder (Curaçao and Sint Maarten), effective 31 March 2025, replacing ANG. So it's correctly placed in the standard KNOWN_FIAT list rather than the documented non-ISO exceptions (IRT/GGP/IMP/JEP/MLC) that's why it isn't in that exceptions comment; it doesn't need to be.

The X prefix is the likely source of confusion (it groups visually with XAU/XAG/XPT, which are correctly dropped as metals), but XCG is a real circulating fiat same category as XCD (East Caribbean dollar), XAF/XOF (CFA francs) and XPF, all already uncontested in the list.

Concretely: XCG is in the captured live Yadio fixture in this PR's own test, and it showed up in my live tick (price: XCG now has a single source ... then published). So dropping it would violate the PR's stated invariant "Phase 2 must not silently drop a currency nodes were publishing yesterday" by removing a currency the node is publishing today.

If a doc nit helps, a one-line comment next to XCG ("ISO 4217 Amendment 176, eff. 2025-03-31, Caribbean guilder, replaces ANG") would pre-empt the same question in future.

@grunch grunch merged commit 14299b1 into main Jun 16, 2026
8 checks passed
@grunch grunch deleted the feat/price-phase2-backup-quoters branch June 16, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants