Skip to content

feat: add Nostr trusted-node price provider (#697) - #841

Open
ToRyVand wants to merge 9 commits into
MostroP2P:mainfrom
ToRyVand:feat/697-nostr-price-provider
Open

feat: add Nostr trusted-node price provider (#697)#841
ToRyVand wants to merge 9 commits into
MostroP2P:mainfrom
ToRyVand:feat/697-nostr-price-provider

Conversation

@ToRyVand

@ToRyVand ToRyVand commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #697. Adds a nostr price provider so mostrod can source BTC/fiat
quotes by subscribing to kind-30078 (NIP-33) rate events published by
trusted Mostro nodes, instead of hitting an HTTP price API — for operators
in regions where those APIs are network-blocked (the motivating case:
api.yadio.io is DNS-blocked for Venezuelan ISPs; PR #685 already solved
this for mobile clients, this closes the gap for the node operator).

It slots into the existing multi-source PriceProvider registry
(src/price/, docs/PRICE_PROVIDERS.md) as one more adapter — no changes
to the aggregation core, the store, the scheduler, or any order handler.

  • src/price/providers/nostr.rs (new): NostrProvider queries the
    process-wide Nostr client (already connected to [nostr]'s relays — no
    new connections) for kind=30078, authors=trusted_nodes, d=mostro-rates
    once per tick, re-verifies each returned event's pubkey against
    trusted_nodes client-side, and takes the freshest valid event as
    the tick's source. With several trusted_nodes configured this is
    redundancy (if one is down/stale, a fresher one wins), not a statistical
    combine across nodes — kept deliberately simple, see docs/PRICE_PROVIDERS.md
    §11.7 for the rationale.
  • src/price/manager.rs, src/price/providers/mod.rs: registry wiring
    (build_provider arm + module declaration).
  • settings.tpl.toml: commented [price.providers.nostr] template block.
  • docs/PRICE_PROVIDERS.md: new §11.7 appendix section (the code already
    referenced it) + a note on the Phase table (§8) that this is an
    out-of-band addition to the original publish-only 5-phase plan.
  • src/price/config.rs: fixes ProviderConfig::validate() — the
    trusted_nodes exemption from requiring a url was previously generic
    across any provider id, so e.g. copy-pasting trusted_nodes onto
    [price.providers.yadio] would silently pass startup validation and only
    fail later with a confusing per-tick HTTP error against an empty URL. Now
    scoped to nostr only, with a regression test.

Test plan

  • cargo build — clean.
  • cargo test price:: — 117 passed (includes new parse_content,
    select_freshest, constructor, and registry-wiring tests).
  • cargo clippy --all-targets --all-features — no issues.
  • cargo fmt --check — clean.
  • Live-relay evidence: an #[ignore]d test
    (price::providers::nostr::tests::live_relay_fetch_returns_real_rates,
    not run in CI) exercises the real fetch() path against
    wss://relay.mostro.network using the two example trusted_nodes
    pubkeys from the issue. Run manually:
    cargo test price::providers::nostr::tests::live_relay_fetch_returns_real_rates -- --ignored --exact
    — returned 140 live currencies (USD, EUR, CUP, MLC, ARS, …),
    confirming the whole path (client → filter → freshest-selection →
    parse) works end-to-end against production infrastructure, not just
    fixtures.

Summary by CodeRabbit

  • New Features
    • Added a Nostr-based price provider for BTC/fiat rates using kind 30078 “mostro-rates” events from configured trusted nodes.
    • Selects the freshest valid quote and ignores untrusted or outdated events.
    • Added nostr as a selectable price-provider option.
  • Documentation
    • Added configuration guidance for trusted nodes.
  • Bug Fixes
    • Improved provider configuration validation and fallback behavior.
  • Tests
    • Expanded coverage for parsing, freshness, validation, and fallback scenarios.

Lets mostrod source BTC/fiat quotes by subscribing to kind-30078 rate
events from trusted Mostro nodes over Nostr instead of an HTTP API, for
operators in regions where price APIs are network-blocked (e.g. Yadio
DNS-blocked in Venezuela/Cuba). Reuses the process-wide Nostr client
already connected to [nostr]'s relays; with several trusted_nodes
configured, the freshest valid event wins over a cross-node combine.

Also scopes ProviderConfig::validate()'s trusted_nodes exemption to
the nostr provider only, so it can no longer mask a missing url on any
other provider.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a nostr price provider that validates trusted node pubkeys, queries kind-30078 rate events through configured relays, selects the freshest valid event, and integrates the provider into price configuration and PriceManager.

Changes

Nostr price provider

Layer / File(s) Summary
Provider contract and configuration
src/price/provider.rs, src/price/config.rs, settings.tpl.toml, docs/PRICE_PROVIDERS.md
Adds the Nostr provider identifier, trusted_nodes configuration, Nostr-specific URL validation, configuration examples, and parsing and validation tests.
Trusted-node relay provider
src/price/providers/nostr.rs, src/price/providers/mod.rs
Adds NostrProvider, validates public keys, parses BTC rate events, filters invalid quotes, selects the freshest valid event, queries relays, and tests these behaviors.
Price-manager integration and compatibility tests
src/price/manager.rs, src/price/providers/*
Registers NostrProvider in the factory, applies fallback and republish rules, and updates existing provider test configurations for trusted_nodes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PriceManager
  participant NostrProvider
  participant NostrClient
  participant TrustedNodeRelays
  PriceManager->>NostrProvider: fetch()
  NostrProvider->>NostrClient: query kind-30078 events
  NostrClient->>TrustedNodeRelays: filter trusted authors and mostro-rates
  TrustedNodeRelays-->>NostrClient: matching events
  NostrClient-->>NostrProvider: event list
  NostrProvider-->>PriceManager: freshest valid quotes
Loading

Suggested reviewers: grunch

Poem

A rabbit checked rates on a relay bright,
Trusted keys filtered each event in sight.
Fresh quotes hopped to the manager’s door,
While stale and empty ones counted no more.
“Nostr is ready!” the rabbit said.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR adds the provider but does not implement the issue's requested price_source modes or always republish Nostr-sourced prices [#697]. Implement or document equivalent price_source modes and align publishing behavior with the linked issue requirements.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the addition of the Nostr trusted-node price provider, which is the main change.
Out of Scope Changes check ✅ Passed The configuration, provider, manager, tests, and documentation changes all support the linked Nostr price-feed objective [#697].
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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.

🧹 Nitpick comments (2)
src/price/providers/nostr.rs (2)

24-28: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

RELAY_QUERY_TIMEOUT is decoupled from the shared provider_timeout_seconds.

This 8s constant is independent of the operator-configurable provider_timeout_seconds (default 10, no fallback_urls for this provider ⇒ manager's poll_budget = provider_timeout_seconds + 1). At the default that's only ~3s of slack; if an operator lowers provider_timeout_seconds globally (e.g. to speed up other HTTP providers) below ~9s, this provider's ticks would consistently hit the manager's outer timeout instead of completing (or gracefully erroring) on their own — which is especially unfortunate given this provider exists specifically for higher-latency/blocked-network operators.

Consider deriving the relay-query timeout from the provider's own budget (or documenting/enforcing a minimum safe provider_timeout_seconds when nostr is enabled) rather than a fixed constant untied to the shared knob.

🤖 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/providers/nostr.rs` around lines 24 - 28, Update the Nostr provider
timeout configuration around RELAY_QUERY_TIMEOUT so the relay query derives from
the shared provider_timeout_seconds budget and retains sufficient outer-timeout
slack, rather than using a fixed 8-second duration. Ensure lowered global
budgets do not cause fetch() to be consistently terminated by
PriceManager::poll_budget before the provider completes or reports its own
error.

102-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

fetch()'s filter construction is untested outside the ignored live-relay test.

The kind/authors/identifier filter shape and the relay-error/no-event mapping in fetch() are only exercised by live_relay_fetch_returns_real_rates, which is #[ignore]d and never runs in CI. A regression here (wrong kind constant, wrong identifier, etc.) would go unnoticed until a manual run against a live relay.

Consider extracting the Filter::new()... construction into a small pub(crate) fn build_filter(&self) -> Filter (mirroring parse_content/select_freshest's testability split), so its shape can be asserted in a plain unit test without a relay.

🤖 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/providers/nostr.rs` around lines 102 - 130, The fetch() filter
construction is not covered by runnable unit tests. Extract the existing
Filter::new() chain from NostrProvider::fetch into a pub(crate)
build_filter(&self) -> Filter method, preserving the kind, trusted-node authors,
and "mostro-rates" identifier values, then update fetch() to use it and add a
non-live unit test asserting the filter shape.
🤖 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/providers/nostr.rs`:
- Around line 24-28: Update the Nostr provider timeout configuration around
RELAY_QUERY_TIMEOUT so the relay query derives from the shared
provider_timeout_seconds budget and retains sufficient outer-timeout slack,
rather than using a fixed 8-second duration. Ensure lowered global budgets do
not cause fetch() to be consistently terminated by PriceManager::poll_budget
before the provider completes or reports its own error.
- Around line 102-130: The fetch() filter construction is not covered by
runnable unit tests. Extract the existing Filter::new() chain from
NostrProvider::fetch into a pub(crate) build_filter(&self) -> Filter method,
preserving the kind, trusted-node authors, and "mostro-rates" identifier values,
then update fetch() to use it and add a non-live unit test asserting the filter
shape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf834974-afcf-4195-9b1e-03d2452244a2

📥 Commits

Reviewing files that changed from the base of the PR and between ec4a046 and 7f40c01.

📒 Files selected for processing (12)
  • docs/PRICE_PROVIDERS.md
  • settings.tpl.toml
  • src/price/config.rs
  • src/price/manager.rs
  • src/price/provider.rs
  • src/price/providers/blockchain.rs
  • src/price/providers/coingecko.rs
  • src/price/providers/currency_api.rs
  • src/price/providers/eltoque.rs
  • src/price/providers/mod.rs
  • src/price/providers/nostr.rs
  • src/price/providers/yadio.rs

…stroP2P#697)

CodeRabbit review on PR MostroP2P#841 flagged two nitpicks:

- RELAY_QUERY_TIMEOUT was a fixed 8s constant, decoupled from the
  operator-configurable provider_timeout_seconds. Lowering that
  setting below ~9s would make the nostr provider's ticks consistently
  hit PriceManager::poll_budget's outer timeout instead of completing
  or failing on their own. NostrProvider now derives query_timeout
  from provider_timeout_seconds (threaded through build_provider),
  matching the 1s of slack every other provider already gets.
- fetch()'s Filter construction was only exercised by the #[ignore]d
  live-relay test. Extracted into NostrProvider::build_filter(),
  unit-tested for kind/authors/identifier without touching a relay.
@ToRyVand

Copy link
Copy Markdown
Contributor Author

Pushed `b7d5e4c` addressing both CodeRabbit nitpicks:

  • `RELAY_QUERY_TIMEOUT` is no longer a fixed 8s constant — `NostrProvider` now derives its relay-query timeout from the shared `provider_timeout_seconds` (threaded through `build_provider`), so it stays in sync if that setting changes instead of risking the outer `poll_budget` timeout cutting it off first.
  • Extracted the `Filter` construction into `NostrProvider::build_filter()`, now covered by a plain unit test asserting kind/authors/identifier — no longer only exercised by the `#[ignore]`d live-relay test.

`cargo build`, `cargo test` (11 passed, 1 ignored), `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --check`: all clean.

@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

🤖 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/providers/nostr.rs`:
- Line 136: Update the event-processing flow around select_freshest and the
fetch_events call to remove trusted mostro-rates events whose NIP-40 expiration
tag is in the past before selecting the newest event. Preserve trusted-author
and created_at selection behavior for non-expired events, and add coverage for a
newest expired event versus an older valid event and for trusted nodes with only
expired events.
🪄 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 Plus

Run ID: c4eed9e2-8d37-4ffa-a1b9-b8850bdc0a46

📥 Commits

Reviewing files that changed from the base of the PR and between 7f40c01 and b7d5e4c.

📒 Files selected for processing (2)
  • src/price/manager.rs
  • src/price/providers/nostr.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/price/manager.rs

Comment thread src/price/providers/nostr.rs Outdated
Discard trusted-node kind-30078 events older than
max_price_staleness_seconds so zombie relay data cannot refresh
the local cache clock.

Co-authored-by: Cursor <cursoragent@cursor.com>
@arkanoider

Copy link
Copy Markdown
Collaborator

Maintainer follow-up: discard stale Nostr rate events

Pushed dff055e3 onto this branch.

Problem. get_price / the store TTL only look at as_of (when this node last wrote the aggregate). On every successful Nostr poll we set as_of = now, even if the trusted-node kind-30078 event is days old. Relays often keep expired NIP-40 events, so a dead or idle trusted node could keep refreshing the cache clock with zombie rates — especially bad for the nostr-only / API-blocked case this provider targets.

Fix. NostrProvider::select_freshest now also requires now - created_at ≤ [price].max_price_staleness_seconds (same shared TTL as the store). If every trusted event is missing, untrusted, or too old, fetch returns Err so the tick counts as a provider failure (breaker / other sources / last-known-good) instead of an empty success that still looks “fresh”.

Wiring. max_price_staleness_seconds is passed into NostrProvider::new the same way provider_timeout_seconds already is — no new public from_settings API.

Tests. Unit coverage for discard-older, all-stale → none, and exact max-age boundary.

@arkanoider

Copy link
Copy Markdown
Collaborator

@ToRyVand tACK and good job! I posted a fix to avoid staleness of nostr event that was not checked, take a look on your side and then for me it's green for merge.

@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

🤖 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/providers/nostr.rs`:
- Around line 141-146: Update the event filtering in the rate-selection flow to
reject trusted events whose created_at timestamp is later than now before
applying the max_age freshness check. Preserve selection of the newest valid
current event in max_by_key, and add a test covering a future-dated trusted
event alongside a current event, asserting the current event is selected.
🪄 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 Plus

Run ID: 19928e09-a972-48af-93e8-af55064bfa7a

📥 Commits

Reviewing files that changed from the base of the PR and between b7d5e4c and dff055e.

📒 Files selected for processing (2)
  • src/price/manager.rs
  • src/price/providers/nostr.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/price/manager.rs

Comment thread src/price/providers/nostr.rs Outdated
A forged or clock-skewed trusted-node event with created_at > now would
saturate the age check to 0 and always win max_by_key, bypassing the
staleness gate entirely. Reject created_at > now before the age check.

Addresses CodeRabbit finding on PR MostroP2P#841 after arkanoider's dff055e.
@ToRyVand

Copy link
Copy Markdown
Contributor Author

Thanks @arkanoider — the TTL fix in dff055e3 looks right, and it closes a real gap the original PR left open.

CodeRabbit caught one more edge in that same commit worth fixing before merge: select_freshest's freshness check (now.saturating_sub(created_at) <= max_age) only rejects events that are too old. A future-dated created_at (forged or clock-skewed relay) saturates that subtraction to 0, passes the check trivially, and then wins max_by_key outright — the exact zombie-rate scenario the TTL fix was meant to close, just from the other direction.

Pushed e52f93f: rejects created_at > now before the age check, plus a test (select_freshest_rejects_future_dated_events) asserting a future-dated event loses to a legitimately current one. cargo fmt --check, clippy -D warnings, and the full price:: test suite (123 tests) are all green.

Should be good for another look whenever you get a chance.

@ermeme ermeme 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 — changes requested

Reviewed the full PR history and exact head e52f93fb32f347eedabfb33a8c3f91b380b511de.

Blocking

  1. Expired NIP-40 events are still eligible. select_freshest (src/price/providers/nostr.rs:141-149) checks only author and created_at; it never checks Event::is_expired_at. The created-at TTL is not equivalent to the event's signed expiration. With the defaults, this daemon publishes rate events that expire after 600 seconds (src/price/manager.rs:523-535) while the consumer accepts their created_at for 1,800 seconds, so a relay retaining expired events can keep them eligible for another 20 minutes. I reproduced this on the current head: a newer expired event wins over an older non-expired event. The existing unresolved CodeRabbit thread remains valid. Please reject expired events before selection and cover both “newest expired vs older valid” and “all expired”.

  2. Nostr-derived prices can be recursively republished with a fresh timestamp, bypassing staleness indefinitely. The provider discards upstream age/provenance when converting the selected event into ordinary quotes (src/price/providers/nostr.rs:170-180); the manager then stamps the aggregate with local now and publishes a new kind-30078 event with a new created_at/expiration (src/price/manager.rs:311-319, 523-537). If a node trusts itself, or two relay-mode nodes trust each other, old rates can circulate and be re-signed forever during the original source outage. max_price_staleness_seconds then never trips. Add an explicit anti-loop/provenance policy and regression coverage for self-trust and a two-node relay-only cycle.

Prior review points

  • Fixed on current head: shared timeout derivation, unit-tested filter construction, stale created_at rejection, and future-dated event rejection.
  • Still open: NIP-40 expiration handling, as detailed above.

Verification

  • cargo fmt --check — passed
  • cargo clippy --all-targets --all-features -- -D warnings — passed
  • cargo test price:: — 123 passed, 1 ignored
  • Focused expiration mutation probe — failed as described (the expired event was selected)
  • Checkout clean and still at the reviewed head

@ermeme ermeme 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.

Supplemental strict-review findings

The parallel audits completed after my initial review. I verified these additional blockers against the same live head, e52f93fb32f347eedabfb33a8c3f91b380b511de:

  1. The newest malformed/empty event suppresses valid redundant nodes. select_freshest chooses one event before content validation (src/price/providers/nostr.rs:135-149,170-180). If the newest trusted event has malformed JSON, fetch() returns a parse error without trying the next-newest fresh event. If its body is {"BTC":{}} or contains only unusable values, it returns Ok(empty) and still does not try another trusted node. This contradicts the documented “highest created_at among the trusted, valid ones” redundancy guarantee (docs/PRICE_PROVIDERS.md:873-881). Please order eligible events newest-first and select the first one that parses to a non-empty usable quote map, with tests for malformed and empty newest events versus an older valid event.

  2. An already-aggregated remote event is counted as an independent local vote. The Nostr adapter discards the event's source provenance and emits one ordinary Quote::PerBtc per currency (src/price/providers/nostr.rs:113-125). The manager then combines it equally with local HTTP providers (src/price/manager.rs:273-292; src/price/aggregate.rs:129-183). A remote aggregate can contain the same Yadio/CoinGecko inputs already present locally, double-counting correlated data and defeating the median/outlier guard. Concrete example under the current 5% rule: local [Yadio=100, Blockchain=200] aggregates to 150; adding a remote aggregate derived from Yadio at 100 produces [100,100,200], rejects 200 as an outlier, and returns 100. Make Nostr an explicit fallback rather than a concurrent independent sample, or retain enough upstream provenance to deduplicate overlapping contributors before aggregation. Add a regression test for this correlated-source case.

These are separate from the NIP-40 and recursive-republication blockers in the initial review.

@arkanoider

arkanoider commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@ToRyVand the bot seems ranting again, take a look and verify if it's a real issue, reading quickly they seem correct. I missed in my fixed the Event::is_expired_at thing. Take a look again please, and fix. We are more than happy to merge this new nostr currency source.

CodeRabbit flagged that select_freshest never checked a rate event's own
NIP-40 expiration tag, only the max_price_staleness_seconds TTL against
this node's clock. A relay that keeps serving an expired kind-30078 event
past its self-declared expiry could still win max_by_key. Filter with
Event::is_expired_at before selecting the freshest event.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/price/providers/nostr.rs (1)

146-154: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate candidate events before using their quotes.

select_freshest currently trusts any event whose pubkey matches trusted_nodes, without rechecking the Nostr envelope (kind=30078, identifier("mostro-rates")) or event authenticity. Since the relay-side subscription filter can be spoofed or bypassed and JSON deserialization does not verify signatures, call Event::verify() on before-selection candidates, reject malformed envelopes, and add tests for an invalid signature and wrong envelope.

🤖 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/providers/nostr.rs` around lines 146 - 154, Update select_freshest
to validate each trusted candidate with Event::verify() before selecting its
quote, and require the expected Nostr envelope: kind 30078 with identifier
"mostro-rates". Reject candidates with invalid signatures, malformed envelopes,
or mismatched metadata while preserving the existing freshness and expiration
filters, and add tests covering invalid signatures and wrong envelopes.

Source: MCP tools

🤖 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.

Outside diff comments:
In `@src/price/providers/nostr.rs`:
- Around line 146-154: Update select_freshest to validate each trusted candidate
with Event::verify() before selecting its quote, and require the expected Nostr
envelope: kind 30078 with identifier "mostro-rates". Reject candidates with
invalid signatures, malformed envelopes, or mismatched metadata while preserving
the existing freshness and expiration filters, and add tests covering invalid
signatures and wrong envelopes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c21c5c0f-443e-45c0-91ae-4560a51180d1

📥 Commits

Reviewing files that changed from the base of the PR and between e52f93f and c493844.

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

@ermeme ermeme 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 re-review — changes still requested

Reviewed the full PR history and exact current head c4938447b15d6f39627b55455264b776a2b60cac, including the delta from e52f93f.

Fixed

  • NIP-40 expiration: fixed correctly. select_freshest now rejects Event::is_expired_at, and the two new tests cover newest-expired/older-valid and all-expired cases.

Still blocking

  1. Nostr-derived prices can still be republished indefinitely. The consumer drops upstream age/provenance (nostr.rs:115-125,175-185), then the manager stores and republishes the aggregate with a fresh local timestamp and expiration (manager.rs:311-319,523-537). Self-trust or an A↔B trust cycle can therefore keep an old quote fresh forever. Prevent republishing Nostr-derived-only aggregates as fresh observations, or preserve and enforce the oldest origin timestamp/provenance.

  2. The newest malformed/empty event still suppresses a valid redundant node. Selection happens before parsing (nostr.rs:139-154,175-185). I reproduced this on the current head: a newer trusted not json event was selected over an older valid trusted event, and the regression probe failed. {"BTC":{}} likewise returns an empty success. Rank candidates newest-first, but choose the first one that parses to a non-empty usable quote map.

  3. The remote aggregate is still counted as an independent local vote. The adapter drops the event's source tag, and aggregate_tick treats ProviderId::Nostr as independent from local HTTP inputs (nostr.rs:115-125; manager.rs:273-292; aggregate.rs:129-193). A remote Yadio-derived 100 combined with local Yadio 100 and Blockchain 200 produces 100 instead of the local two-source result 150. Make Nostr fallback-only when direct providers have usable quotes, or preserve and deduplicate complete upstream provenance.

  4. Kind/d are not revalidated client-side. The expected envelope exists only in build_filter (nostr.rs:106-110); select_freshest does not check kind == 30078 or d == mostro-rates. In the locked nostr-relay-pool 0.44.1, verify_subscriptions defaults to false, and connect_nostr() does not enable it, so a noncompliant relay can return a validly signed trusted-author event outside the requested filter. Validate the exact envelope before ranking candidates. I am not carrying CodeRabbit's signature-verification claim as a blocker: the SDK relay pipeline already calls event.verify() before delivering newly received events.

Verification

  • Branch head: cargo fmt --check passed; focused Nostr tests passed (17 passed, 1 ignored); full cargo test price:: passed (125 passed, 1 ignored); Clippy with -D warnings passed.
  • Synthetic integration with live origin/main (94e736a7d3fad24a548d13f4541f4af9eb44ace6): merge clean; price suite and Clippy passed.
  • git diff --check passed.

The NIP-40 fix is good, but the other previously requested safety/correctness fixes are not present in this commit.

@arkanoider

Copy link
Copy Markdown
Collaborator

@ToRyVand the bot are bad guys, still ranting on other points...

Addresses ermeme's re-review (CHANGES_REQUESTED, commit c493844):

- Revalidate the event envelope (kind 30078, d=mostro-rates) client-side
  in rank_candidates instead of trusting the relay-side query filter —
  nostr-relay-pool's verify_subscriptions defaults to false, so a
  noncompliant relay could hand back a validly-signed event from a
  trusted pubkey under a different kind/d.
- Split select_freshest into rank_candidates (all valid candidates,
  newest first) + pick_first_usable, so a malformed or empty body from
  the newest trusted event no longer suppresses a good, still-fresh body
  from an older one.
- Make the Nostr provider strictly fallback-only in aggregate_tick
  (restrict_nostr_to_fallback): a trusted node's rate is itself already
  an aggregate of its own direct sources, so blending it into this
  node's median/mean alongside those same kinds of sources double-counts
  correlated information.
- Stop republishing currencies whose only surviving contributor is Nostr
  (republishable_rates): re-stamping a relayed rate with a fresh
  created_at/expiration erases how old the underlying quote really is,
  which a trust cycle could otherwise ride indefinitely.
@ToRyVand

Copy link
Copy Markdown
Contributor Author

Pushed d5e91de addressing all 4 points from the strict re-review.

2 & 4 (nostr.rs) — mechanical, fully closed:

  • select_freshestrank_candidates (returns every valid candidate, newest first) + pick_first_usable (tries them in order until one parses to a non-empty rate map). A malformed/empty body from the newest trusted event no longer suppresses a good older one — 3 new tests cover this.
  • rank_candidates now revalidates kind/d client-side instead of trusting the relay-side query filter, since verify_subscriptions defaults to false in the locked nostr-relay-pool — 2 new tests cover a wrong-kind and a wrong-d trusted event.

3 (manager.rs) — Nostr made fallback-only: restrict_nostr_to_fallback drops the Nostr provider's quotes for any currency another provider already covered that tick, before aggregate_tick runs. A trusted node's rate is itself already a combine of its own direct sources, so blending it in alongside this node's direct sources was double-counting correlated info. 2 new tests.

1 (manager.rs) — republish guard, partial: republishable_rates stops republishing any currency whose only surviving contributor is Nostr — a re-stamped created_at/expiration on a purely-relayed rate was erasing how old the underlying quote really is. This closes the common case, but I want to flag its limit honestly: it's a mitigation, not full provenance tracking. A currency that's genuinely Nostr-only forever (an operator fully cut off from direct APIs, which is this feature's actual target user) still won't propagate past one hop under this fix — by design, since we don't know its true age without a separate origin-timestamp tag. Closing that fully needs a NIP-40-adjacent protocol addition (an origin created_at distinct from the outer event's), which touches docs/NOSTR_EXCHANGE_RATES.md and is really a spec-level follow-up, not something I want to improvise into this PR under time pressure. Happy to open a tracking issue for it if you agree with that split.

Verification: cargo fmt --check, clippy --all-targets --all-features -D warnings, full price:: suite (134 tests, up from 123) all green.

@arkanoider

Copy link
Copy Markdown
Collaborator

tACK for me! Let's wait a pass from a bot to complete it.

@arkanoider

Copy link
Copy Markdown
Collaborator

Local verification of ermeme blockers (head d5e91deb)

Checked out the same commit as the PR head (d5e91debbd071fe65afe70cfb9cd8329e2cb227a) and walked every point that was still marked blocking in ermeme's reviews (including the earlier NIP-40 item that was already closed on c493844).

Verdict: all requested blockers are satisfied on this head

# Blocker (ermeme) Status on d5e91de Where
0 NIP-40 expired events still eligible Fixed (already on c493844) rank_candidates filters !e.is_expired_at(&now); tests cover newest-expired vs older-valid and all-expired
1 Indefinite republish / trust-cycle freshness Fixed (option A from the review) republishable_rates drops currencies whose only surviving contributor is Nostr before publish_rates_to_nostr
2 Newest malformed/empty event suppresses a valid redundant node Fixed rank_candidates (newest-first) + pick_first_usable; tests for malformed, empty, and all-unusable
3 Remote aggregate counted as an independent local vote Fixed (fallback-only) restrict_nostr_to_fallback in aggregate_tick keeps Nostr only for currencies no other provider covered that tick
4 Kind / d not revalidated client-side Fixed rank_candidates requires kind == 30078 and d == mostro-rates; tests for wrong-kind and wrong-d

Notes

  • Blocker 1 is closed via the review's first alternative ("prevent republishing Nostr-derived-only aggregates as fresh observations"), not full origin-timestamp provenance. That matches the author's stated scope split; a protocol-level origin age tag remains a reasonable follow-up for multi-hop Nostr-only operators, but it is not required to clear this review item as written.
  • Blocker 3's concrete double-count example is addressed by dropping overlapping Nostr quotes before aggregation (direct sources alone decide the value when they cover the currency).

Verification run locally

  • cargo test price::134 passed, 0 failed, 1 ignored

tACK from my side on the correctness items above — the remaining open review from ermeme[bot] looks stale relative to d5e91de.

@grunch

grunch commented Jul 31, 2026

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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 (2)
src/price/manager.rs (1)

508-515: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider recomputing the source tag from the republished set.

republishable_rates drops Nostr-only currencies, but successes (from report.contributors at Line 326) still covers every aggregate. If the only currency that Nostr contributed to is dropped here, the published source tag still names nostr while no published value came from it. Deriving the tag from the surviving currencies keeps the tag consistent with the payload.

🤖 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 508 - 515, Update the republishing flow
around republishable_rates and the published source-tag construction to derive
source contributors from the surviving rates rather than the full successes set.
Exclude Nostr when its contributed currencies are all removed, while preserving
source tags for contributors represented in the republished payload.
src/price/providers/nostr.rs (1)

275-277: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the stale select_freshest references.

The function is now rank_candidates. The doc comments at Lines 276 and 294 still name select_freshest, and the tests at Lines 314-422 keep the select_freshest_* prefix. Rename them to rank_candidates_* so the tests point at the function they exercise.

♻️ Example rename
-    /// Like `signed_event`, but carrying a NIP-40 `expiration` tag — for
-    /// exercising `select_freshest`'s `is_expired_at` gate independently of
-    /// the `max_age` gate.
+    /// Like `signed_event`, but carrying a NIP-40 `expiration` tag — for
+    /// exercising `rank_candidates`'s `is_expired_at` gate independently of
+    /// the `max_age` gate.
As per coding guidelines: "use descriptive test names such as `handles_expired_hold_invoice`".
🤖 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/providers/nostr.rs` around lines 275 - 277, Rename the stale
select_freshest references in the documentation comments near the signed-event
helpers to rank_candidates, and rename every select_freshest_* test in the
affected test block to the rank_candidates_* prefix. Keep the test behavior
unchanged and use descriptive names for each specific scenario.

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`:
- Around line 591-613: Update restrict_nostr_to_fallback so currency keys from
both non-Nostr coverage and Nostr filtering are normalized to uppercase before
comparison, matching aggregate_tick behavior. Add a regression test covering a
lowercase non-Nostr currency code and an uppercase Nostr code for the same
currency, verifying the Nostr quote is excluded.

---

Nitpick comments:
In `@src/price/manager.rs`:
- Around line 508-515: Update the republishing flow around republishable_rates
and the published source-tag construction to derive source contributors from the
surviving rates rather than the full successes set. Exclude Nostr when its
contributed currencies are all removed, while preserving source tags for
contributors represented in the republished payload.

In `@src/price/providers/nostr.rs`:
- Around line 275-277: Rename the stale select_freshest references in the
documentation comments near the signed-event helpers to rank_candidates, and
rename every select_freshest_* test in the affected test block to the
rank_candidates_* prefix. Keep the test behavior unchanged and use descriptive
names for each specific scenario.
🪄 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 Plus

Run ID: 01b93be7-38dd-4571-a644-11c9e4bc38cd

📥 Commits

Reviewing files that changed from the base of the PR and between e52f93f and d5e91de.

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

Comment thread src/price/manager.rs
@arkanoider

Copy link
Copy Markdown
Collaborator

@ToRyVand i think we are ready for merge, fix last coderabbit nipticks and we should be ok!

`restrict_nostr_to_fallback` compared raw currency keys, but
`aggregate_tick` upper-cases every code before grouping. Not every
adapter normalises on the way out — Yadio forwards the API's codes
verbatim — so a direct `usd` and a Nostr `USD` slipped past the filter
and then landed in the same aggregate, producing exactly the
double-count the function exists to prevent.

Fold both sides of the comparison before matching, and cover the case
with a regression test that fails on the raw comparison.
Two review nitpicks from the last CodeRabbit pass:

`publish_rates_to_nostr` tagged the event with the tick-wide contributor
list while `republishable_rates` may drop currencies from the body. A
provider whose every contribution was filtered out was still named in
the `source` tag of a payload carrying none of its data. Derive the tag
from the surviving currencies instead, which makes the `successes`
parameter redundant.

Also rename the stale `select_freshest` references left over from the
rename to `rank_candidates`, matching the prefix the envelope tests
already use.
@ToRyVand

ToRyVand commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

CodeRabbit items addressed — plus one new finding worth your call

Pushed two commits on top of d5e91de, kept separate so the correctness change is reviewable on its own.

2de2902 — case-insensitive fallback coverage (the actionable comment on manager.rs:613)

restrict_nostr_to_fallback compared raw currency keys while aggregate_tick upper-cases every code before grouping. Not every adapter normalises on the way out — yadio.rs:50 forwards the API's codes verbatim — so a direct usd and a Nostr USD slipped past the filter and then landed in the same aggregate. I confirmed the added regression test fails on the raw comparison and passes with the fold.

4c58f07 — the two nitpicks

The source tag is now derived from the currencies that survive republishable_rates rather than the tick-wide contributor list, so it can no longer name a provider absent from the published body. That made the successes parameter redundant, so it is gone. Also renamed the leftover select_freshest references to rank_candidates, matching the prefix the envelope tests already use.

Gates: cargo test price:: 136 passed / 1 ignored (was 134), cargo fmt --check and cargo clippy --all-targets --all-features -- -D warnings clean.


New finding — an unresolvable PerBase suppresses a usable Nostr quote

This surfaced while verifying the above, and I reproduced it on the pushed head.

restrict_nostr_to_fallback measures coverage from raw quote keys at manager.rs:298, before aggregate_tick runs at manager.rs:300. El Toque covers CUP/MLC only as USD-anchored PerBase quotes, and aggregate.rs:93 drops a PerBase silently when its anchor is missing. So El Toque "covers" CUP, Nostr's usable direct PerBtc CUP is discarded, El Toque then fails to resolve, and CUP ends up with no price at all.

Repro — El Toque CUP as PerBase { base: "USD", value: 300.0 }, Nostr CUP as PerBtc(30_000_000.0), no direct USD that tick — yields aggregates = {}.

The trigger is narrow: it needs a trusted rate event carrying CUP but not USD. But the failure mode is no price rather than a wrong one, in exactly the blocked-API deployment §11.7 targets.

I did not fix it here, because both obvious minimal fixes are wrong:

  • Letting only PerBtc establish coverage re-introduces the Nostr/direct blending that this restriction exists to prevent, for every currency whose cross does resolve.
  • Pre-aggregating the non-Nostr providers to derive coverage misjudges it in precisely the deployment at issue, where the USD anchor legitimately comes from Nostr itself.

A correct fix means determining coverage after anchor resolution — a real restructure and a new review surface on a PR that is otherwise ready. Happy to do it in this PR if you would rather not ship the gap, or to file it as a follow-up. Your call.

Note on the accepted as_of deferral

Not re-opening this, just putting a number on it: nostr.rs:98 sets the provider's max_age to max_price_staleness_seconds, and the store stamps as_of = now at manager.rs:321. So an event accepted at 1799s old is re-stamped fresh and served for another 1800s — roughly 2× the configured budget on the defaults. That is the provenance item already accepted as a follow-up; flagging the magnitude only.

@ermeme ermeme 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 re-review — changes still requested

Reviewed the full PR conversation and exact current head 4c58f070a92df5158f8eaeabc75010b0cb87d0f7.

Fixed from the previous review

  • NIP-40 expiration, future-date, and max-age event gates.
  • Malformed/empty newest-event fallback.
  • Client-side kind / d envelope validation.
  • Direct Nostr-only republication guard.
  • Correlated-source fallback, including case-insensitive currency matching.
  • Published source tag derivation and stale naming nitpicks.

Blocking

  1. An unresolved PerBase quote suppresses a usable Nostr fallback (src/price/manager.rs:298-300, helper at :595-617). Coverage is decided from raw quote keys before anchor resolution. If El Toque returns CUP as PerBase { base: "USD", ... }, Nostr returns a usable direct CUP, and no USD anchor exists, the helper drops Nostr's CUP first; aggregation then drops the unresolved El Toque quote and produces no CUP price. I reproduced this on the current head with a temporary regression test: expected Some(30_000_000.0), got None. Determine non-Nostr coverage after resolution, and add this regression test.

  2. Fiat-cross resolution drops Nostr provenance before the republication guard (src/price/aggregate.rs:141-163, src/price/manager.rs:628-653). When Nostr supplies the USD anchor and El Toque supplies CUP/USD, the resulting CUP is attributed only to El Toque, so republishable_rates republishes a Nostr-derived value with a fresh timestamp and source=eltoque. Preserve/track anchor provenance (or Nostr-derived taint) through cross resolution and test Nostr USD → El Toque CUP.

  3. Relay responses are collected without a local event-count bound before validation (src/price/providers/nostr.rs:218-226). With subscription-filter verification disabled in the locked relay pool, a malicious relay can stream arbitrarily many validly signed irrelevant events until timeout; fetch_events() force-inserts them before local author/envelope/freshness filtering and sorting. The timeout bounds time, not memory/work. Enforce a hard local bound while receiving and reject irrelevant events before unbounded collection; add an adversarial test.

Verification

  • cargo fmt --check: passed
  • cargo test price::: 136 passed, 1 ignored
  • Full cargo test: 1,056 passed, 2 ignored across targets
  • cargo clippy --all-targets --all-features -- -D warnings: passed
  • git diff --check: passed

The previous blockers are substantially addressed, but these paths still need correction before merge.

Determine Nostr fallback coverage after PerBase resolution, taint
crosses that embed a Nostr anchor so they are not republished, and
bound streamed relay events before ranking.

Co-authored-by: Cursor <cursoragent@cursor.com>
@arkanoider

Copy link
Copy Markdown
Collaborator

Addressed ermeme's three remaining blockers (a8a6aef2)

Pushed a follow-up on top of 4c58f070 that closes the three items from the latest strict re-review.

1. Unresolved PerBase no longer suppresses a usable Nostr fallback

Fix. restrict_nostr_to_fallback now decides coverage after fiat-cross resolution via currencies_covered_by_non_nostr:

  • Anchors are built from all direct PerBtc quotes (including Nostr), so a local El Toque CUP/USD that needs Nostr's USD still counts as covering CUP.
  • A non-Nostr PerBase only covers a currency when value × anchor is finite and positive.
  • Raw key presence alone is no longer enough — El Toque CUP without a USD anchor no longer drops Nostr's usable PerBtc CUP.

Tests. restrict_nostr_to_fallback_keeps_nostr_when_per_base_cannot_resolve, restrict_nostr_to_fallback_drops_nostr_when_per_base_resolves_via_nostr_usd.

2. Nostr-anchor taint tracked through cross resolution

Fix. AggregateResult gains nostr_anchor_dependent. aggregate_tick sets it when a PerBase resolves against an anchor whose surviving contributors include Nostr. republishable_rates now drops those currencies as well as pure Nostr-only ones — so El Toque CUP × Nostr USD is not re-stamped as a fresh source=eltoque observation.

contributors still names only the cross provider (anchor providers remain intermediates for the source tag).

Tests. aggregate_tick_marks_cross_resolved_via_nostr_usd_as_anchor_dependent, republishable_rates_drops_nostr_anchor_dependent_cross.

3. Local bound on relay event collection

Fix. fetch streams via Client::stream_events instead of unbounded fetch_events/force_insert. While receiving it:

  • early-rejects non-trusted / wrong-kind / wrong-d frames (is_plausible_candidate);
  • hard-caps retained candidates at MAX_FETCHED_RATE_EVENTS (64);
  • also caps inspected frames under a flood of junk;
  • still sets Filter::limit for compliant relays.

Tests. collect_bounded_candidates_drops_junk_and_caps_trusted_envelope.

Verification

  • cargo fmt --check / cargo clippy --all-targets --all-features -- -D warnings — clean
  • cargo test price::141 passed, 1 ignored

Happy to adjust further if anything still looks off on a re-review of a8a6aef2.

@arkanoider

Copy link
Copy Markdown
Collaborator

Hi @ToRyVand I created a commit to fix latest @hermemes bot rants, I will check them better tomorrow with my eyes. Please take a look and evaluate also on your side.

@ermeme ermeme 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 re-review — one blocker remains

Reviewed the full conversation and exact current head a8a6aef23871aee42712204abcc190fb6650364a.

Fixed

  • The unresolved PerBase path no longer suppresses a usable Nostr fallback.
  • Nostr anchor dependence is now tracked through fiat-cross resolution and excluded from republication.

Still blocking

The new application-level cap does not bound memory in the locked relay-pool stream implementation (src/price/providers/nostr.rs:266-285).

Client::stream_events delegates to nostr-relay-pool 0.44.1's pooled stream. Before events reach this loop, RelayPool::stream_events_targeted inserts every unique event ID into an unbounded HashSet<EventId> (pool/mod.rs:1271-1295). Subscription-filter verification is still disabled, so a malicious relay can send arbitrarily many unique, validly signed but irrelevant events. The application stops inspecting after 256 frames, but dropping its receiver does not stop the spawned pool driver: it keeps consuming until timeout, keeps inserting IDs, and ignores failed sends. Therefore the previous remote memory/work DoS remains below the new cap.

Please bound/reject at or before the relay-pool driver (for example, enable and verify subscription enforcement with mismatch handling, or use an actually bounded relay-level receive path), and add a test that exercises the production stream boundary rather than only the pure collect_bounded_candidates helper.

Verification

  • cargo fmt --check: passed
  • cargo test price::: 141 passed, 1 ignored
  • Full cargo test: 1,061 passed, 2 ignored across targets
  • cargo clippy --all-targets --all-features -- -D warnings: passed
  • git diff --check: passed

The two aggregation fixes are good; only the stream-boundary DoS remains.

@ermeme

ermeme Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up from two independent audits of a8a6aef2: the existing CHANGES_REQUESTED review remains valid, and two additional edge paths should be covered by the same fix round.

  1. The inspected-frame cutoff can starve an honest relay (src/price/providers/nostr.rs:273-284). Events are counted after relay streams are merged. One noncompliant relay can fill the first 256 slots with irrelevant, validly signed events, causing the application to break before an honest relay's valid trusted-node candidate is yielded. This converts the memory guard into a deterministic provider-level DoS. The production-boundary fix should cancel/reject the offending relay rather than apply one global first-N cutoff.

  2. Nostr-anchor taint is set before final target-currency outlier filtering (src/price/aggregate.rs:166-210). resolved_nostr_anchor becomes true as soon as any valid Nostr-anchored cross is created, even if that cross is later rejected by the final combine. Example: local target quotes 100 and 101, plus a Nostr-anchored cross 10000, aggregate to the fully local 100.5, but remain marked nostr_anchor_dependent and are wrongly excluded from republication at manager.rs:686-690. Track taint only from candidates that survive the target currency's outlier predicate, and add a regression test.

The two primary aggregation blockers from the previous review are otherwise fixed. The relay-pool unbounded dedup state remains the main blocker in the formal review.

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.

Feature: Price Feed via Nostr (Relay Mode) for Blocked Regions

3 participants