Skip to content

feat(price): Phase 4 — unify live quote path on cache + enforce staleness#783

Open
grunch wants to merge 1 commit into
mainfrom
feat/price-phase4-unify-staleness
Open

feat(price): Phase 4 — unify live quote path on cache + enforce staleness#783
grunch wants to merge 1 commit into
mainfrom
feat/price-phase4-unify-staleness

Conversation

@grunch

@grunch grunch commented Jun 18, 2026

Copy link
Copy Markdown
Member

Phase 4 of docs/PRICE_PROVIDERS.md

Unifies the live market-quote path onto the multi-source cache and turns on staleness (TTL) enforcement. Depends on mostro-core 0.13.1 (MostroP2P/mostro-core#153), which adds ServiceError::PriceTooStale and CantDoReason::PriceTooStale.

What changed

Live path → cache

  • get_market_quote (util.rs) is now a synchronous cache read — sats = (fiat_amount / get_price(ccy)) × 1e8 with the premium applied — instead of a live Yadio /convert call (with 4 retries + blocking thread::sleep) on every take. No network I/O on the take path.
  • Removed the dead live-path code: retries_yadio_request, yadio_base_url / select_yadio_base_url / normalize_base_url, MAX_RETRY, FiatNames, and the Yadio/Request response model (src/models.rs deleted). With the live path gone, the legacy [mostro].bitcoin_price_api_url now feeds only legacy synthesis.
  • get_market_amount_and_fee is now sync and returns MostroError so callers can distinguish the stale case.

Staleness enforcement (spec §6.4)

  • PriceManager::get_price returns ServiceError::PriceTooStale for a value older than max_price_staleness_seconds (still warning once on the transition), instead of serving stale data.
  • Order create (app/order.rs) and market-priced takes (app/take_buy.rs, app/take_sell.rs) map it onto a user-facing CantDoReason::PriceTooStale.

Test evidence

  • New unit test stale_price_is_refused_but_fresh_is_served: past-TTL → PriceTooStale, within-TTL → served, never-seen currency → NoAPIResponse.
  • Full suite green (488 tests), clippy --all-targets --all-features -D warnings clean, cargo fmt applied.

How to test manually

Prereqs: an LND/regtest mostrod setup as usual, with a [price] section enabled in settings.toml.

1. Happy path — market-priced order prices from the cache (no live /convert)

  1. Start mostrod. Wait for the first price tick (price: ... ok (N currencies) in the logs).
  2. Create a market-priced order (amount 0) in a fiat like USD, and take it.
  3. Confirm the sats amount looks right for the current rate, and that no GET .../convert/... request is made during create/take (the old live call is gone). The only price HTTP traffic is the scheduler's periodic provider polls.

2. Staleness refusal — past the TTL

  1. Set a tiny window in settings.toml:
    [price]
    update_interval_seconds = 300
    max_price_staleness_seconds = 30
  2. Start mostrod, let it fetch once, then block provider refreshes so the cached value ages out — e.g. disable the providers or cut network to the price APIs (firewall/airplane) while keeping the daemon running.
  3. After > 30s with no fresh tick, try to create a market-priced order (or take one) in a currency that aggregated earlier.
    • Expected: the request is refused with CantDoReason::PriceTooStale (client shows a "price too stale" cant-do), not a priced order.
    • Logs show price: <CCY> is past the staleness window (<n>s old) — refusing once (one-shot warning), not per read.
  4. Fixed-amount (non-market) orders in the same currency are unaffected — they don't price off the cache.

3. Recovery — fresh tick re-arms

  1. Restore provider connectivity.
  2. After the next successful tick, retry the create/take from step 2 — it should now succeed.
  3. A subsequent slide past the TTL warns again (the guard re-armed on the fresh read).

4. Currency with no data

  1. Request a market-priced order in a fiat no provider reports.
  2. Expected: refused (internal NoAPIResponse → existing behaviour), distinct from the stale path.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Price staleness is now enforced; orders will be rejected with stale pricing data instead of proceeding with outdated information.
  • Documentation

    • Updated price provider documentation to reflect Phase 4 completion and staleness enforcement behavior.

…ness

Completes Phase 4 of docs/PRICE_PROVIDERS.md.

Live path → cache:
- `get_market_quote` is now a synchronous cache read,
  `sats = (fiat_amount / get_price(ccy)) × 1e8` with the premium applied,
  instead of a live Yadio `/convert` call per take. No network I/O on the
  take path.
- Delete the dead live-path code: `retries_yadio_request`,
  `yadio_base_url`/`select_yadio_base_url`/`normalize_base_url`,
  `MAX_RETRY`, `FiatNames`, and the `Yadio`/`Request` response model
  (`src/models.rs` removed). With the live path gone, the legacy
  `[mostro].bitcoin_price_api_url` now feeds only legacy synthesis.
- `get_market_amount_and_fee` becomes sync and returns `MostroError` so
  callers can distinguish the stale case.

Staleness enforcement (spec §6.4):
- `PriceManager::get_price` returns `ServiceError::PriceTooStale` for a
  value older than `max_price_staleness_seconds` (still warning once on
  the transition) instead of serving it.
- Order create (`app/order.rs`) and market-priced takes
  (`app/take_buy.rs`, `app/take_sell.rs`) map it onto a user-facing
  `CantDoReason::PriceTooStale`.

Depends on the new error variants in mostro-core 0.13.1
(MostroP2P/mostro-core#153).

Tests: new `stale_price_is_refused_but_fresh_is_served`; full suite green
(488 tests), clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NvSncWrjedLiAtAV4DTJR
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Phase 4 of the multi-source BTC/fiat price rollout: mostro-core is bumped to 0.13.1 for new PriceTooStale error variants; PriceManager::get_price now refuses stale cache entries; the legacy async Yadio HTTP market-quote path and its models are deleted and replaced with a synchronous cached-price read; call sites in order-create and take flows map the new error to CantDoReason::PriceTooStale.

Changes

Phase 4 Price Staleness Enforcement

Layer / File(s) Summary
Dependency bump and legacy Yadio model removal
Cargo.toml, src/main.rs
mostro-core bumped to 0.13.1 for PriceTooStale variants; pub mod models; removed from src/main.rs (along with the Yadio and Request structs it exported).
PriceManager::get_price staleness enforcement
src/price/manager.rs
Doc comments updated to Phase 4 semantics. PriceError::TooStale branch changed to return MostroInternalErr(ServiceError::PriceTooStale) instead of serving the stale value; one-shot warning now includes cached age. New stale_price_is_refused_but_fresh_is_served test asserts the enforcement end-to-end.
Sync cache-based market quote
src/util.rs
Async Yadio HTTP get_market_quote (URL selection, retries, response parsing) deleted and replaced with a synchronous wrapper over get_bitcoin_price that applies the premium in memory. get_market_amount_and_fee changed from async to synchronous; Yadio URL unit tests removed.
PriceTooStale propagation at call sites
src/app/order.rs, src/app/take_buy.rs, src/app/take_sell.rs
calculate_and_check_quote, take_buy_action, and take_sell_action each remove .await from get_market_amount_and_fee calls and add explicit PriceTooStale branches that return MostroCantDo(CantDoReason::PriceTooStale) to the user.
Documentation update
docs/PRICE_PROVIDERS.md
Phase 3 marked done in the overview table. Phase 4 shipped-status notes document the cache-only quote formula, deletion of the Yadio /convert path, TTL enforcement producing PriceTooStale, and confirmation that mostro-core 0.13.1 ships the required variants.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • MostroP2P/mostro#499: Modifies the same legacy retries_yadio_request HTTP path in src/util.rs that this PR deletes entirely.
  • MostroP2P/mostro#745: Introduced the multi-provider / Yadio-removal spec in docs/PRICE_PROVIDERS.md that this PR marks as shipped in Phase 4.
  • MostroP2P/mostro#747: Established the price module, store, and config abstractions in src/price/manager.rs that Phase 4 now enforces staleness against.

Suggested reviewers

  • arkanoider
  • BraCR10
  • Catrya
  • AndreaDiazCorreia
  • codaMW

Poem

🐇 The Yadio calls have hopped away,
No more retries to start the day.
A cache is fresh, a cache is stale—
Past the TTL? You shall not prevail!
PriceTooStale returns with grace,
Phase 4 enforced, right in your face. 🥕

🚥 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 summarizes the main change: Phase 4 unifies the live quote path to use cache-based approach and enforces staleness checks, which is precisely what the PR implements.
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-phase4-unify-staleness

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: 2397684c18

ℹ️ 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/app/take_buy.rs
// If the order amount is zero, calculate the market price in sats
if order.has_no_amount() {
match get_market_amount_and_fee(order.fiat_amount, &order.fiat_code, order.premium).await {
match get_market_amount_and_fee(order.fiat_amount, &order.fiat_code, order.premium) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck market quotes when taker bonds lock

When taker bonds are enabled, this call snapshots order.amount and fees before creating the bond; request_taker_bond stores those values in the bond's taker_* columns, and promote_taker_context_to_order later copies them when the invoice is accepted. If the taker pays the bond after max_price_staleness_seconds has elapsed, the trade still proceeds with that old market quote instead of returning PriceTooStale, so bonded market-priced takes can bypass the Phase 4 staleness guarantee. Revalidate/reprice before promoting the bond, or expire/reject stale bond snapshots.

Useful? React with 👍 / 👎.

@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/util.rs (1)

1071-1081: ⚡ Quick win

Add /// docs for get_market_amount_and_fee

This public helper returns a tuple with positional semantics; add a short doc comment describing tuple order and error behavior.

As per coding guidelines: "Document non-obvious public APIs with /// doc comments."

🤖 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/util.rs` around lines 1071 - 1081, Add `///` doc comments to the public
function `get_market_amount_and_fee` to document its non-obvious behavior.
Document each parameter (fiat_amount, fiat_code, and premium), clearly explain
the return tuple's positional semantics (first element is satoshi amount, second
element is the fee), and document the error conditions that may result in
MostroError. Place the doc comments immediately before the function signature.

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 401-417: The `warned_stale` state is currently shared between two
different warning paths: the within-TTL stale warning in the `observe_freshness`
method (around line 448) and the past-TTL warning in the current block (around
line 401). This causes the past-TTL transition warning to be suppressed if the
within-TTL stale warning has already been issued for the same key. To fix this,
separate the warning state by creating two distinct warning trackers instead of
sharing one `warned_stale` variable - one specifically for tracking within-TTL
stale warnings and another for tracking past-TTL refused warnings. Update the
`mark_warned` calls to use the appropriate warning state based on the context.

---

Nitpick comments:
In `@src/util.rs`:
- Around line 1071-1081: Add `///` doc comments to the public function
`get_market_amount_and_fee` to document its non-obvious behavior. Document each
parameter (fiat_amount, fiat_code, and premium), clearly explain the return
tuple's positional semantics (first element is satoshi amount, second element is
the fee), and document the error conditions that may result in MostroError.
Place the doc comments immediately before the function signature.
🪄 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: fa0736ae-9e26-4d39-82b2-e0665d10ac42

📥 Commits

Reviewing files that changed from the base of the PR and between 82f1923 and 2397684.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • Cargo.toml
  • docs/PRICE_PROVIDERS.md
  • src/app/order.rs
  • src/app/take_buy.rs
  • src/app/take_sell.rs
  • src/main.rs
  • src/models.rs
  • src/price/manager.rs
  • src/util.rs
💤 Files with no reviewable changes (2)
  • src/models.rs
  • src/main.rs

Comment thread src/price/manager.rs
Comment on lines +401 to +417
if self.mark_warned(&self.warned_stale, &key) {
let age = self
.store
.snapshot(currency)
.map(|e| now.saturating_sub(e.as_of));
match age {
Some(age) => warn!(
"price: {} is past the staleness window ({}s old) — refusing",
currency, age
);
),
None => warn!(
"price: {} is past the staleness window — refusing",
currency
),
}
Ok(entry.value)
} else {
// Should not happen: TooStale means an entry exists,
// but tolerate the race in case the entry was wiped
// between get and snapshot.
Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse))
}
Err(MostroError::MostroInternalErr(ServiceError::PriceTooStale))

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Past-TTL transition warning can be suppressed after a prior within-TTL stale read

warned_stale is shared between the past-TTL path (Line 401) and observe_freshness (Line 448). If the key is already inserted by the within-TTL stale warning, the PriceError::TooStale branch will skip its transition warning.

Consider separate warning state for “within-TTL stale” vs “past-TTL refused”.

🤖 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 401 - 417, The `warned_stale` state is
currently shared between two different warning paths: the within-TTL stale
warning in the `observe_freshness` method (around line 448) and the past-TTL
warning in the current block (around line 401). This causes the past-TTL
transition warning to be suppressed if the within-TTL stale warning has already
been issued for the same key. To fix this, separate the warning state by
creating two distinct warning trackers instead of sharing one `warned_stale`
variable - one specifically for tracking within-TTL stale warnings and another
for tracking past-TTL refused warnings. Update the `mark_warned` calls to use
the appropriate warning state based on the context.

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

Reviewed on regtest against mostro-core 0.13.1. The core Phase 4 change is sound, I verified the mechanics, demonstrated the staleness enforcement live, and dug into the two open bot findings. One (the bonded-take bypass) is real and I was able to reproduce it end-to-end; details below. Not blocking from my side, but worth a decision on scope before merge.
Mechanics
• Builds clean against mostro-core 0.13.1 (Rust 1.93); cargo clippy --all-targets --all-features -- -D warnings clean.
• cargo test: 487 pass. The one failure is test_lnurl_validation_with_test_server (AddrInUse), a pre-existing port-bind flake unrelated to this PR (fails on main too).
• The new stale_price_is_refused_but_fresh_is_served unit test passes.
Staleness enforcement verified live. With a [price] block (max_price_staleness_seconds set low) and the cache aged past the window, the daemon refused market-priced requests with price: is past the staleness window (s old) refusing and returned CantDoReason::PriceTooStale. So the core guarantee works at runtime, not just in the unit test.
Config gate worth flagging. Phase 4 enforcement only engages when a [price] block is present; on a legacy config (no [price]), the daemon runs the legacy synthesis path and market orders proceed regardless of cache age (confirmed at runtime orders on a no-[price] config were not refused). Suggest documenting that Phase 4 protection requires migrating to a [price] block, or consider a sensible default guard for legacy configs.
On the Codex P2 finding (bonded takes bypassing staleness) confirmed by code trace AND reproduced end-to-end. Code path: take_buy runs the staleness check in get_market_amount_and_fee (take_buy.rs:122), then request_taker_bond snapshots the quote into bond.taker_amount (bond/flow.rs:183). When the bond locks, promote_taker_context_to_order copies that snapshot into the order (order.amount = bond.taker_amount, bond/flow.rs ~1069) with no re-pricing or staleness re-check. Reproduced with two funded regtest LND nodes + a mobile-app taker, apply_to = take, max_price_staleness_seconds = 60:

  1. Took a market-priced order while the price was fresh -> bond snapshotted taker_amount = 33196.
  2. Blocked the price provider so the cache aged past the window (direct market takes were refused with price_too_stale throughout the stale period).
  3. ~10 min into the stale window, paid the bond hold invoice -> daemon logged Bond … locked, promoted the order to waiting-payment with amount = 33196, and issued the trade invoice for that amount, no staleness re-check, despite the price being stale the entire time. So a bonded market take completes on a pre-TTL quote that the direct path refuses. Scope: only bites with bonds enabled and a delayed bond payment, and the fix (revalidate/expire the snapshot before promoting) belongs to the bond flow rather than this price PR. I'd treat it as a tracked follow-up, not a blocker but the Phase 4 guarantee should probably be documented as direct path only until the bonded path re-checks.
    On the CodeRabbit warned_stale finding confirmed, minor/logging-only. The past-TTL refuse path (manager.rs:401) and the within-TTL stale path (manager.rs:448) both call mark_warned(&self.warned_stale, key) on the same set, so a within-TTL warning already recorded for a key suppresses the past-TTL refusing log line. The refusal itself still fires (the Err(PriceTooStale) returns regardless) only the operator warning can be swallowed. Non-blocking; separate trackers would fix it.
    Housekeeping: the branch currently shows merge conflicts against main needs a rebase before merge.
    Net: the Phase 4 core is solid and I'm comfortable with it landing after the rebase. The bonded-take staleness gap is real but reasonably a follow-up rather than a blocker for this PR. Nice deletion of the whole live-Yadio retry path.

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.

2 participants