Skip to content

feat(price): Phase 1 — Yadio provider + PriceManager wiring#753

Open
grunch wants to merge 4 commits into
mainfrom
feat/price-phase-1-yadio
Open

feat(price): Phase 1 — Yadio provider + PriceManager wiring#753
grunch wants to merge 4 commits into
mainfrom
feat/price-phase-1-yadio

Conversation

@grunch
Copy link
Copy Markdown
Member

@grunch grunch commented May 28, 2026

Summary

Second atomic PR of the multi-source price rollout (docs/PRICE_PROVIDERS.md §9, Phase 1). Builds on Phase 0 (#747).

  • Wires Phase 0 into the daemon at single-source parity. New PriceManager owns the provider registry + store + HTTP client; the scheduler's job_update_bitcoin_prices now drives PriceManager::update_all, and util::get_bitcoin_price reads through it.
  • YadioProvider lives at src/price/providers/yadio.rs (GET {url}/exrates/BTC, lenient Option<f64> parse so null rates are dropped — preserves the d3f5bfa fix). Captured-payload fixture + parse tests.
  • Legacy config migration (§10.1). When [price] is absent we synthesise a single-yadio config from [mostro].bitcoin_price_api_url + exchange_rates_update_interval_seconds + publish_exchange_rates_to_nostr, so existing settings.toml files keep working byte-for-byte.
  • src/bitcoin_price.rs shrunk to the §9 Phase 1 shim (BitcoinPriceManager::get_price delegates to PriceManager); 423 lines removed, the rest retires in Phase 5.
  • Phase 1 staleness behaviour preserved (§9). get_price logs a single warn! when a value ages past one update interval but still returns it — Phase 1 never refuses an order that would have priced today. Enforcement turns on in Phase 4.

What is NOT in this PR

  • Keyless backups (CoinGecko, currency-api, Blockchain) — Phase 2.
  • El Toque fiat-cross adapter — Phase 3.
  • get_market_quote unification + staleness enforcement — Phase 4.
  • Aggregated-source Nostr publishing polish, info-event exposure, bitcoin_price.rs removal — Phase 5.

Single designated extension point (§5.4 Step 3)

build_provider is the single match arm a new provider touches; the aggregation core, the store, and the scheduler stay untouched. An enabled-but-unimplemented provider fails startup; an unknown id is warn-and-skipped (forward-compat with newer config from an older binary).

Test evidence

  • Phase 1 acceptance (§9):
    • single-yadio tick returns the captured fixture's values verbatim,
    • yadio-down preserves prior values (no panic, no wipe),
    • no enabled providers → NoAPIResponse matching today's "no data yet".
  • Per-provider only / except scoping (§6.6) enforced at the manager boundary.
  • Captured Yadio payload exercises USD/EUR/ARS/CUP plus the BGN: null regression case.
  • Deterministic Nostr source-tag ordering across ticks.

cargo fmt, cargo clippy --all-targets --all-features, cargo test (365 passing) all green.

Test plan

  • cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test green on CI
  • Smoke run: start a node with the legacy settings.toml (no [price] block) — confirm logs show PriceManager installed, price: yadio ok (N currencies) each tick, and orders that exercise get_bitcoin_price (market-priced create) succeed
  • Smoke run: enable the new [price.providers.yadio] block — same behaviour
  • Smoke run: kill the network briefly (iptables block on api.yadio.io) — confirm the store keeps serving last-known-good values, warn! fires once the value ages past one update interval, and orders still succeed (Phase 1 logs but does not refuse)
  • Verify the Nostr kind-30078 event still publishes with source tag yadio (now derived from the contributing-providers list, not hard-coded)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-source price providers with a Yadio adapter and optional publishing of aggregated rates to Nostr.
    • Configuration template updated (new provider block, polling/freshness, timeouts, outlier handling); legacy single-provider keys remain supported.
  • Refactor

    • Centralized, process-wide price manager and aggregation pipeline; startup installs the manager and scheduler now uses it.
  • Bug Fixes / Improvements

    • Per-tick reporting, better logging for full/partial outages, and more accurate contributor attribution in aggregated rates.

Review Change Stack

…ource parity)

Second atomic PR of the multi-source price rollout
(docs/PRICE_PROVIDERS.md §9, Phase 1). Wires Phase 0's foundation into the
daemon at single-source parity: Yadio behind the new abstraction, the rest
of the daemon untouched outside the call sites.

Provider:
- src/price/providers/yadio.rs: YadioProvider via GET {url}/exrates/BTC,
  lenient Option<f64> parse (drops `null` / non-finite rates, preserving
  the db99f94 fix), parse() helper unit-tested against a captured fixture.
- tests/fixtures/price/yadio_btc.json: captured payload exercising
  USD/EUR/ARS/CUP plus the `BGN: null` regression case.

Manager:
- src/price/manager.rs: PriceManager owning Vec<Box<dyn PriceProvider>> +
  PriceStore + reqwest::Client. update_all() polls each provider with its
  own tokio::time::timeout, runs aggregate_tick (applying per-provider
  only/except scoping at the boundary so aggregate.rs stays generic), and
  writes the store; failed providers contribute nothing so prior values
  survive as last-known-good (§6.4). get_price() logs a single warn! when
  a value ages past one update interval but still returns it — Phase 1
  never refuses an order that would have priced today (§9, enforcement
  lands in Phase 4). build_provider() is the single designated
  extension point (§5.4 Step 3); unknown ids in config are warn-and-skip
  (forward-compat); an enabled-but-unimplemented adapter fails startup.
- Nostr publishing preserved with the legacy {"BTC": {ccy: value}}
  wrapper; the `source` tag becomes the contributing provider list
  (deterministically sorted), so today it's "yadio" and Phase 2 widens
  it without changing the schema.
- Process-wide PriceManager::global() singleton via OnceLock; installed
  in main right after settings_init().

Wiring:
- scheduler::job_update_bitcoin_prices now drives PriceManager::update_all;
  interval comes from [price].update_interval_seconds, MIN_INTERVAL guard
  preserved.
- util::get_bitcoin_price reads through PriceManager.
- src/bitcoin_price.rs shrunk to the shim required by §9 Phase 1
  (BitcoinPriceManager::get_price delegates to PriceManager); the rest
  retires in Phase 5.

Config migration (§10.1):
- When [price] is absent, synthesise_legacy_price_settings() builds a
  single yadio provider from bitcoin_price_api_url +
  exchange_rates_update_interval_seconds + publish_exchange_rates_to_nostr,
  so existing settings.toml files keep working byte-for-byte.
- settings.tpl.toml documents the new [price] block as opt-in and marks
  bitcoin_price_api_url deprecated.

Tests (+11, 365 total green):
- single-yadio tick matches today, yadio-down keeps prior values, no
  providers → NoAPIResponse (§9 Phase 1 acceptance criteria).
- only-scoping enforced at the manager boundary (§6.6).
- from_settings rejects enabled-but-not-implemented (CoinGecko etc.),
  ignores unknown ids, skips disabled.
- legacy migration validates; deterministic source-tag ordering.
- Yadio fixture parse + null/non-finite drop + trailing-slash URL +
  parse-error surfacing.

cargo fmt, cargo clippy --all-targets --all-features, cargo test all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Warning

Review limit reached

@grunch, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 12 minutes and 13 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2108036b-cabe-43fd-aa08-1a25bd60398e

📥 Commits

Reviewing files that changed from the base of the PR and between f98f093 and c391414.

📒 Files selected for processing (1)
  • src/price/aggregate.rs

Walkthrough

Phase 1 price subsystem: adds a process-global PriceManager with multi-provider aggregation and optional Nostr publishing, a Yadio provider adapter and fixtures, startup installation and legacy settings synthesis, and replaces the old BitcoinPriceManager with a compatibility shim.

Changes

Price Subsystem Phase 1 Implementation

Layer / File(s) Summary
Configuration template and deprecation
settings.tpl.toml
Marks mostro.bitcoin_price_api_url deprecated and adds a commented multi-source [price] template with provider stanza and operational guidance.
Yadio provider adapter and fixtures
src/price/providers/mod.rs, src/price/providers/yadio.rs, tests/fixtures/price/yadio_btc.json
Adds YadioProvider with JSON parsing that drops null/non-finite/non-positive rates, implements PriceProvider::fetch, includes unit tests and a BTC fixture.
Aggregation changes (contributors) and tests
src/price/aggregate.rs
AggregateResult gains contributors: Vec<ProviderId>; aggregate_tick now accepts (ProviderId, ProviderQuotes) inputs and computes contributor lists with outlier-aware filtering; tests updated/extended for contributor semantics.
PriceManager core and helpers
src/price/manager.rs
Adds PriceManager singleton, provider wiring, update_all() tick with per-provider timeouts, scoping/aggregation, TickReport (with contributors), staleness warnings, last-known-good preservation, Nostr publishing, helpers, and unit tests.
Price module exports and consumer API
src/price/mod.rs
Exports manager and providers, re-exports PriceManager/TickReport/synthesis helper, and adds get_bitcoin_price(currency) delegating to the global manager or returning NoAPIResponse when uninitialized.
Startup integration and manager installation
src/main.rs
Adds install_price_manager() called after settings init; synthesises legacy settings when needed, constructs and installs the global PriceManager, and logs/install errors.
Legacy shim, scheduler, and util migrations
src/bitcoin_price.rs, src/scheduler.rs, src/util.rs
Reduces bitcoin_price.rs to a shim delegating to crate::price::get_bitcoin_price; scheduler uses PriceManager::global() and update_all() with refined logging and interval sourcing; util delegates to the new API; legacy tests simplified.
ProviderId derive and store helper
src/price/provider.rs, src/price/store.rs
ProviderId gains PartialOrd/Ord; store test helper updated to construct AggregateResult with contributors: Vec::new().

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • Catrya
  • BraCR10

Poem

🐰 I hopped through configs, code, and test,
Gathering rates from providers’ nest,
I stitched old paths to the new manager’s chest,
Nostr will trumpet when aggregates rest —
Legacy shims hum on, peaceable and blessed.

🚥 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 clearly summarizes the main change: Phase 1 implementation adding a Yadio provider and PriceManager wiring to the price subsystem, which aligns with the extensive refactoring across multiple files.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/price-phase-1-yadio

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/scheduler.rs (1)

565-565: ⚡ Quick win

Use the update report to surface provider failures.

At Line 565, _report is dropped. Please emit at least a warn/error from the returned report when no providers succeed (or when failures spike), so price-source outages are visible from scheduler logs.

🤖 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/scheduler.rs` at line 565, The update report returned by
manager.update_all() is currently ignored (assigned to _report); change this so
you capture the returned report (e.g., report) and inspect its success/failure
metrics and per-provider results, then emit a log.warn or log.error from the
scheduler when no providers succeeded or when failure counts spike; locate the
call to manager.update_all() and the binding of _report, read fields on the
returned Report (or its methods) to determine total successes/failures and
provider-level failures, and add conditional logging that surfaces which
providers failed and summary counts so price-source outages are visible in
scheduler logs.
🤖 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`:
- Around line 211-213: The Nostr `source` tag is being built from
report.successes (used in publish_rates_to_nostr and sources_to_tag) which can
include providers filtered out or excluded by aggregation; change the pipeline
so provider identities are those that actually contributed to the final
aggregates: modify aggregate_tick (or the aggregation code) to return
contributor metadata (e.g., a Vec of provider IDs or embed a contributors field
in the aggregate type), update callers (publish_rates_to_nostr and any place
building sources_to_tag) to use that returned contributor list instead of
report.successes, and adjust sources_to_tag to accept and format the actual
contributors; ensure function signatures (aggregate_tick,
publish_rates_to_nostr, sources_to_tag) are updated consistently and tests
updated accordingly.
- Around line 273-285: The TooStale branch currently logs on every call
(PriceError::TooStale in the match), which can spam logs; change this to a
one-shot warning by tracking whether we've already warned for that currency
(e.g., add a per-manager or per-store tracking structure like a HashSet/flag for
warned currencies), check that flag before calling warn! in the
Err(PriceError::TooStale) branch (use self.store.snapshot(currency) as now),
emit warn! only if not already warned, and set the flag after warning; also
clear/unset that flag when a fresh entry is served (where
self.store.snapshot(currency) returns a fresh entry) so future staleness can
warn again.

---

Nitpick comments:
In `@src/scheduler.rs`:
- Line 565: The update report returned by manager.update_all() is currently
ignored (assigned to _report); change this so you capture the returned report
(e.g., report) and inspect its success/failure metrics and per-provider results,
then emit a log.warn or log.error from the scheduler when no providers succeeded
or when failure counts spike; locate the call to manager.update_all() and the
binding of _report, read fields on the returned Report (or its methods) to
determine total successes/failures and provider-level failures, and add
conditional logging that surfaces which providers failed and summary counts so
price-source outages are visible in scheduler logs.
🪄 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: 8c9b5471-d76c-4688-9fa5-ec90d7c76aa6

📥 Commits

Reviewing files that changed from the base of the PR and between 2c022b1 and f8bf7df.

📒 Files selected for processing (10)
  • settings.tpl.toml
  • src/bitcoin_price.rs
  • src/main.rs
  • src/price/manager.rs
  • src/price/mod.rs
  • src/price/providers/mod.rs
  • src/price/providers/yadio.rs
  • src/scheduler.rs
  • src/util.rs
  • tests/fixtures/price/yadio_btc.json

Comment thread src/price/manager.rs
Comment thread src/price/manager.rs
…r outage log

Addresses the three review findings on #753.

1. Nostr `source` tag now reflects **actual contributors**, not the broader
   "polled successfully" list. A provider scoped out by `only`/`except`
   lands in `report.successes` (it did poll OK, so the Phase 2 circuit
   breaker stays happy) but **not** in the new `report.contributors`, so
   the kind-30078 `source` tag never names a provider that didn't move
   the aggregate. Contributors are computed at the manager boundary from
   the post-scope quote maps; tracking outlier-rejected individual quotes
   would require pairing every `Quote` with a `ProviderId` through the
   pure `aggregate_tick`, which is out of scope for Phase 1 and noted as
   a Phase 2 follow-up.

2. `Err(PriceError::TooStale)` in `get_price` no longer logs on every
   call. The single `warned_currencies` HashMap (which also collided
   `Stale` and `SingleSource` flags for the same currency) is split into
   two independent `HashSet`s — `warned_stale` and
   `warned_single_source` — so neither flag clobbers the other. The
   TooStale branch now warns at most once between fresh reads; a fresh
   `Ok` read clears `warned_stale` so a later regression past the TTL
   warns again.

3. The scheduler no longer drops the `TickReport`. PriceManager already
   logs each provider's outcome per tick, so the scheduler only surfaces
   the outage condition that ops cares about: an `error!` when **every**
   provider failed (the store is reading last-known-good across the
   board), and a `warn!` summary on partial outages.

Tests (+2, 367 total):
- `scoped_out_provider_is_success_but_not_contributor`: a provider whose
  only quote is filtered by `only` appears in `report.successes` but not
  in `report.contributors`.
- `stale_warning_is_one_shot_then_re_arms_on_fresh_read`: 10 reads
  against a stale value populate `warned_stale` with one entry; a fresh
  tick + read clears it so future regressions warn again.

cargo fmt, cargo clippy --all-targets --all-features, cargo test all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

Copy link
Copy Markdown

@ermeme ermeme Bot left a comment

Choose a reason for hiding this comment

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

Requesting changes: the source metadata is built from providers that merely returned non-empty quotes before aggregation. That means a provider whose quotes are later discarded as outliers can still be advertised as a contributor in the Nostr source tag. Please derive contributors from the quotes that actually survive aggregation, or have aggregate_tick return provenance so the tag reflects reality.

Addresses the Hermes review on #753: the prior fix computed
contributors at the manager boundary as "post-scope non-empty quotes",
which still claimed a provider as contributor when `combine`'s outlier
filter dropped its value. The Nostr `source` tag advertised providers
that had no effect on the aggregate.

Threads provenance through the pure-function aggregation core instead:

- `aggregate_tick` now takes `&[(ProviderId, ProviderQuotes)]`. Direct
  (PerBtc) quotes and resolved PerBase quotes carry their source id
  through the pipeline.
- `AggregateResult` gains `contributors: Vec<ProviderId>` — sorted,
  deduplicated, and populated by the new `kept_contributors` helper,
  which mirrors `combine`'s "kept" predicate exactly:
  - n≤2: every clean provider contributes,
  - n≥3: only providers whose value is within `outlier_pct` percent of
    the median contribute,
  - bimodal even-length fallback: every clean provider stays (no single
    source is demonstrably the outlier; `combine` falls back to the
    median itself).
- Fiat-cross resolution attributes the resolved candidate to the
  fiat-cross provider only. Anchor contributors (the direct quoters
  whose values built the USD/BTC anchor, say) are an intermediate of
  the cross math, not upstreams of the cross currency.
- Manager derives the tick-wide Nostr `source` tag as the union of
  every per-currency contributor list — using a BTreeSet so the result
  is deterministic without re-sorting in `sources_to_tag`. The
  manager-boundary "non-empty post-scope" heuristic is gone.
- `ProviderId` gains `Ord` / `PartialOrd` so the sorted contributor
  lists are stable.

Tests (+3, 370 total):
- `aggregate_tick_outlier_drops_provider_from_contributors`: three
  providers, one outlier (75_000 against a 50_000/50_200 median) — the
  outlier appears in `sources=3` but NOT in `contributors`.
- `aggregate_tick_bimodal_fallback_keeps_all_clean_contributors`: four
  values across two clusters land in the bimodal fallback; all four
  providers stay contributors.
- `aggregate_tick_non_finite_value_drops_provider_from_contributors`: a
  NaN-emitting provider is dropped from the cleaned set, not advertised
  as a contributor.

Existing aggregate_tick tests assert on the new contributors field;
the unions-partial-coverage test pins the CUP contributor list to
[Yadio (direct), ElToque (fiat-cross)] — confirming the anchor's own
contributors are NOT propagated into the resolved-currency tag.

cargo fmt, cargo clippy --all-targets --all-features, cargo test all
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

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

470-493: ⚡ Quick win

Assert the exact contributor list in the bimodal fallback test.

len() == 4 won't catch a regression where this path returns the right set in nondeterministic order. Since contributors is part of the contract now, it's worth pinning the full sorted vector here too.

🧪 Tighten the assertion
         approx(out["USD"].value, 51.0); // (2+100)/2
-                                        // All four clean providers survive the bimodal fallback.
-        assert_eq!(out["USD"].contributors.len(), 4);
+        // All four clean providers survive the bimodal fallback, in deterministic order.
+        let mut expected = vec![
+            ProviderId::Yadio,
+            ProviderId::CoinGecko,
+            ProviderId::CurrencyApi,
+            ProviderId::Blockchain,
+        ];
+        expected.sort();
+        assert_eq!(out["USD"].contributors, expected);
🤖 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/aggregate.rs` around lines 470 - 493, The test
aggregate_tick_bimodal_fallback_keeps_all_clean_contributors should assert the
exact contributor list rather than just contributors.len(); replace the loose
length check with a deterministic equality check of the sorted contributors
vector so nondeterministic ordering can't hide regressions: extract the provider
identifiers from out["USD"].contributors, sort them deterministically (e.g. by
provider enum/name), and assert they equal the expected list [ProviderId::Yadio,
ProviderId::CoinGecko, ProviderId::CurrencyApi, ProviderId::Blockchain]; update
the test around aggregate_tick and the contributors field to perform that exact
sorted equality check.
🤖 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.

Nitpick comments:
In `@src/price/aggregate.rs`:
- Around line 470-493: The test
aggregate_tick_bimodal_fallback_keeps_all_clean_contributors should assert the
exact contributor list rather than just contributors.len(); replace the loose
length check with a deterministic equality check of the sorted contributors
vector so nondeterministic ordering can't hide regressions: extract the provider
identifiers from out["USD"].contributors, sort them deterministically (e.g. by
provider enum/name), and assert they equal the expected list [ProviderId::Yadio,
ProviderId::CoinGecko, ProviderId::CurrencyApi, ProviderId::Blockchain]; update
the test around aggregate_tick and the contributors field to perform that exact
sorted equality check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cffa729e-e165-4367-99f6-43c14cec6b3f

📥 Commits

Reviewing files that changed from the base of the PR and between 79f12c7 and f98f093.

📒 Files selected for processing (4)
  • src/price/aggregate.rs
  • src/price/manager.rs
  • src/price/provider.rs
  • src/price/store.rs
✅ Files skipped from review due to trivial changes (2)
  • src/price/provider.rs
  • src/price/store.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/price/manager.rs

Replace the loose `.len() == 4` check with the deterministic sorted-list
assertion. `kept_contributors` -> `dedup_sort` orders by the derived
`Ord` on `ProviderId` (which follows enum-variant declaration order:
Yadio, CoinGecko, CurrencyApi, Blockchain, ElToque), so the expected
result is fully determined. A future refactor that perturbs ordering or
silently drops a contributor will now be caught here rather than slipping
past the count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant