feat(cashu): Track B - release happy path#763
Conversation
This implements the Cashu 2-of-3 escrow release happy path as per docs/CASHU_ESCROW_ARCHITECTURE.md (Track B). It bypasses Lightning invoice settlement and directly transitions the order to Success status.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Walkthrough
ChangesCashu Escrow Release Path
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Generates and sends the CashuPmSignature message to the buyer during the happy path release, as a fallback in case the seller fails to send their signature out-of-band via Nostr DM. Ensures the buyer always receives a valid 2nd signature (P_M) if the seller confirms the release to Mostro.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/release.rs (1)
333-343:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
HoldInvoicePaymentSettledis sent to seller even in Cashu mode where no hold invoice exists.This message block runs after the
if is_cashu { ... } else { ... }branches, meaning the seller receivesAction::HoldInvoicePaymentSettledfor Cashu orders despite there being no Lightning hold invoice. This is semantically incorrect and may confuse clients.Consider guarding this with
!is_cashuor introducing a Cashu-specific action.Proposed fix
- // We send a HoldInvoicePaymentSettled message to seller, the client should - // indicate *funds released* message to seller - enqueue_order_msg( - request_id, - Some(order.id), - Action::HoldInvoicePaymentSettled, - None, - seller_pubkey, - None, - ) - .await; + // We send a HoldInvoicePaymentSettled message to seller, the client should + // indicate *funds released* message to seller + if !is_cashu { + enqueue_order_msg( + request_id, + Some(order.id), + Action::HoldInvoicePaymentSettled, + None, + seller_pubkey, + None, + ) + .await; + }🤖 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/app/release.rs` around lines 333 - 343, The code enqueues Action::HoldInvoicePaymentSettled unconditionally even for Cashu orders where no hold invoice exists; update the call sites around enqueue_order_msg in release.rs so that Action::HoldInvoicePaymentSettled is only enqueued when !is_cashu (or replace with a Cashu-specific action), i.e., guard the enqueue_order_msg(...) call with a check on is_cashu (or branch to a Cashu action) to avoid sending HoldInvoicePaymentSettled for Cashu orders.
🤖 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/app/release.rs`:
- Around line 234-260: The token parsing / key conversion / signing currently
swallows errors; update the block that builds pm_signatures so each fallible
operation logs failures (include the offending token_str/order.id and request_id
for context): log errors from
cdk::nuts::Token::from_str(order.cashu_escrow_token), from
cdk::nuts::nut01::SecretKey::from_str(my_keys.secret_key().to_secret_hex()), and
from p_m_secret.sign(&msg) before continuing, and still push any successful
CashuProofSignature::new entries into pm_signatures; use the existing logging
framework (e.g., tracing::error! or the module's logger) and ensure logs mention
functions/symbols like Token::from_str, SecretKey::from_str, p_m_secret.sign,
and CashuProofSignature so failures are diagnosable.
---
Outside diff comments:
In `@src/app/release.rs`:
- Around line 333-343: The code enqueues Action::HoldInvoicePaymentSettled
unconditionally even for Cashu orders where no hold invoice exists; update the
call sites around enqueue_order_msg in release.rs so that
Action::HoldInvoicePaymentSettled is only enqueued when !is_cashu (or replace
with a Cashu-specific action), i.e., guard the enqueue_order_msg(...) call with
a check on is_cashu (or branch to a Cashu action) to avoid sending
HoldInvoicePaymentSettled for Cashu orders.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| let mut pm_signatures = Vec::new(); | ||
| let token_str = order.cashu_escrow_token.as_ref().unwrap(); | ||
| if let Ok(token) = cdk::nuts::Token::from_str(token_str) { | ||
| let secrets = token.token_secrets(); | ||
| if let Ok(p_m_secret) = cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) { | ||
| for secret in secrets { | ||
| let msg = secret.to_bytes(); | ||
| if let Ok(sig) = p_m_secret.sign(&msg) { | ||
| pm_signatures.push(mostro_core::message::CashuProofSignature::new( | ||
| secret.to_string(), | ||
| sig.to_string(), | ||
| )); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if !pm_signatures.is_empty() { | ||
| enqueue_order_msg( | ||
| request_id, | ||
| Some(order.id), | ||
| Action::CashuPmSignature, | ||
| Some(Payload::CashuSignatures(pm_signatures)), | ||
| buyer_pubkey, | ||
| None, | ||
| ).await; | ||
| } |
There was a problem hiding this comment.
Log errors during Cashu signature generation to aid debugging.
The nested if let Ok(...) chains silently discard errors from token parsing, key conversion, and signing. If any step fails, the buyer may receive no CashuPmSignature (or partial signatures), and there's no diagnostic trail. Since P_M signatures are critical for the buyer to redeem the Cashu escrow, failures here should be logged.
Proposed fix with error logging
let token_str = order.cashu_escrow_token.as_ref().unwrap();
- if let Ok(token) = cdk::nuts::Token::from_str(token_str) {
- let secrets = token.token_secrets();
- if let Ok(p_m_secret) = cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
- for secret in secrets {
- let msg = secret.to_bytes();
- if let Ok(sig) = p_m_secret.sign(&msg) {
- pm_signatures.push(mostro_core::message::CashuProofSignature::new(
- secret.to_string(),
- sig.to_string(),
- ));
+ match cdk::nuts::Token::from_str(token_str) {
+ Ok(token) => {
+ let secrets = token.token_secrets();
+ match cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
+ Ok(p_m_secret) => {
+ for secret in secrets {
+ let msg = secret.to_bytes();
+ match p_m_secret.sign(&msg) {
+ Ok(sig) => {
+ pm_signatures.push(mostro_core::message::CashuProofSignature::new(
+ secret.to_string(),
+ sig.to_string(),
+ ));
+ }
+ Err(e) => {
+ tracing::warn!("Order {}: failed to sign Cashu secret: {}", order.id, e);
+ }
+ }
+ }
}
+ Err(e) => {
+ tracing::warn!("Order {}: failed to parse P_M secret key: {}", order.id, e);
+ }
}
}
+ Err(e) => {
+ tracing::warn!("Order {}: failed to parse Cashu escrow token: {}", order.id, e);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let mut pm_signatures = Vec::new(); | |
| let token_str = order.cashu_escrow_token.as_ref().unwrap(); | |
| if let Ok(token) = cdk::nuts::Token::from_str(token_str) { | |
| let secrets = token.token_secrets(); | |
| if let Ok(p_m_secret) = cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) { | |
| for secret in secrets { | |
| let msg = secret.to_bytes(); | |
| if let Ok(sig) = p_m_secret.sign(&msg) { | |
| pm_signatures.push(mostro_core::message::CashuProofSignature::new( | |
| secret.to_string(), | |
| sig.to_string(), | |
| )); | |
| } | |
| } | |
| } | |
| } | |
| if !pm_signatures.is_empty() { | |
| enqueue_order_msg( | |
| request_id, | |
| Some(order.id), | |
| Action::CashuPmSignature, | |
| Some(Payload::CashuSignatures(pm_signatures)), | |
| buyer_pubkey, | |
| None, | |
| ).await; | |
| } | |
| let mut pm_signatures = Vec::new(); | |
| let token_str = order.cashu_escrow_token.as_ref().unwrap(); | |
| match cdk::nuts::Token::from_str(token_str) { | |
| Ok(token) => { | |
| let secrets = token.token_secrets(); | |
| match cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) { | |
| Ok(p_m_secret) => { | |
| for secret in secrets { | |
| let msg = secret.to_bytes(); | |
| match p_m_secret.sign(&msg) { | |
| Ok(sig) => { | |
| pm_signatures.push(mostro_core::message::CashuProofSignature::new( | |
| secret.to_string(), | |
| sig.to_string(), | |
| )); | |
| } | |
| Err(e) => { | |
| tracing::warn!("Order {}: failed to sign Cashu secret: {}", order.id, e); | |
| } | |
| } | |
| } | |
| } | |
| Err(e) => { | |
| tracing::warn!("Order {}: failed to parse P_M secret key: {}", order.id, e); | |
| } | |
| } | |
| } | |
| Err(e) => { | |
| tracing::warn!("Order {}: failed to parse Cashu escrow token: {}", order.id, e); | |
| } | |
| } | |
| if !pm_signatures.is_empty() { | |
| enqueue_order_msg( | |
| request_id, | |
| Some(order.id), | |
| Action::CashuPmSignature, | |
| Some(Payload::CashuSignatures(pm_signatures)), | |
| buyer_pubkey, | |
| None, | |
| ).await; | |
| } |
🤖 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/app/release.rs` around lines 234 - 260, The token parsing / key
conversion / signing currently swallows errors; update the block that builds
pm_signatures so each fallible operation logs failures (include the offending
token_str/order.id and request_id for context): log errors from
cdk::nuts::Token::from_str(order.cashu_escrow_token), from
cdk::nuts::nut01::SecretKey::from_str(my_keys.secret_key().to_secret_hex()), and
from p_m_secret.sign(&msg) before continuing, and still push any successful
CashuProofSignature::new entries into pm_signatures; use the existing logging
framework (e.g., tracing::error! or the module's logger) and ensure logs mention
functions/symbols like Token::from_str, SecretKey::from_str, p_m_secret.sign,
and CashuProofSignature so failures are diagnosable.
There was a problem hiding this comment.
I reviewed the current head of this PR and I am requesting changes.
There are two blocking issues.
- The Cashu mode event loop is explicitly not wiring the trading actions yet.
run_cashu() rejects the core trade actions (NewOrder, TakeSell, TakeBuy, AddInvoice, Release, Cancel, AdminCancel, AdminSettle) with CantDoReason::InvalidAction instead of actually driving the escrow flow.
That means this PR does not just introduce an incomplete backend seam, it starts a Cashu-mode daemon that cannot process the core trading flow at all. That is too incomplete to merge behind an operational mode switch.
- The code still presents the same mixed-mode problem around the "LND not required" claim.
Examples in this head:
src/main.rslogsStarting in Cashu escrow mode (LND not required)- but important Lightning-specific paths still exist directly in
src/util.rs(show_hold_invoice,invoice_subscribe) andsrc/app/bond/flow.rs
So the mode split is still not consistently enforced across the runtime surface.
I also re-checked the Cashu helper code and this branch still carries the same src/cashu/mod.rs clippy issues that were already visible in the sibling work (dead code / useless same-type conversions), which is another sign the branch is not merge-ready.
…t failure (MostroP2P#755) * feat(bond): Phase 4.5 — re-prompt winner for payout invoice on payment failure When a slashed bond's counterparty payout exhausted `payout_max_retries` against a submitted invoice, the bond went straight to `Failed` and the winner was never asked for a new invoice: the scheduler only enumerates `PendingPayout` rows, and the retry loop hammered the *same* unroutable bolt11. The §8.2 "Failed resurrection" recovery existed but only fired if the client spontaneously resubmitted — Mostro never prompted — so in practice the counterparty share stranded and needed operator intervention (issue MostroP2P#750). Phase 4.5 changes the retry-exhaustion transition in `on_send_payment_failure`: - **Inside the claim window**: discard the unroutable invoice and re-arm the invoice-request sub-phase (`payout_invoice`, `payout_routing_fee_sats`, `payout_payment_hash`, `last_invoice_request_at` cleared; `payout_attempts` reset to 0; state stays `PendingPayout`). The next scheduler tick re-prompts the winner via `request_payout_invoice`. `slashed_at` is never touched, so the forfeit deadline does not move and the re-prompt/retry cycle is bounded by `payout_claim_window_days` (→ `Forfeited`). - **Past the claim window**: transition to `Failed` as before (terminal technical failure, operator review). Daemon-only: reuses `Action::AddBondInvoice` (Phase 3) and `Action::BondInvoiceAccepted` (Phase 3.5). No mostro-core bump, no migration. `claim_window_seconds` is threaded through `pay_counterparty` into `on_send_payment_failure` for unit-testability. Spec §9.5 added; phase overview, §8.1/§8.2 cross-refs, and §14.2/§14.3 release status updated to reflect Phases 0–4 merged on main. Closes MostroP2P#750 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): guard re-prompt against double payout on indeterminate failures Codex review on MostroP2P#755 flagged a P1: the Phase 4.5 re-arm path cleared `payout_payment_hash` on every exhausted retry, including non-terminal failures (status-stream timeout, stream EOF, send_payment RPC error) where the original payment may still be InFlight in LND. Clearing the hash disables the reconciliation branch in `pay_counterparty`, so a freshly-prompted invoice (different hash) could be paid while the original payment later settles — a double payout. Introduce `PaymentFailureKind { Terminal, Indeterminate }` and thread it into `on_send_payment_failure`: - Terminal (LND-confirmed Failed, or structurally unusable invoice): no payment is/will be in flight, so the invoice may be abandoned — re-arm in-window, or Failed out-of-window (unchanged Phase 4.5 behaviour). - Indeterminate (timeout / EOF / send RPC error): keep `payout_invoice` and `payout_payment_hash` so reconciliation polls LND to a definitive Succeeded/Failed before anything new is paid. Never re-arms, never flips to Failed. `payout_attempts` saturates at `payout_max_retries` so a long LND outage can't grow it unbounded. The send-status stream now tracks whether it ended on an explicit `PaymentStatus::Failed` (terminal) vs timeout/EOF (indeterminate); the reconciliation-Failed and decode-failure paths are terminal; the send_payment RPC error is indeterminate. Adds `send_payment_indeterminate_failure_keeps_invoice` test; updates the existing exhaustion tests to pass the failure kind. Spec §9.5 documents the double-payout guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2P#767) * feat(bond): Phase 5 — maker bond (non-range) + dispute slash Implements Phase 5 of the anti-abuse bond (docs/ANTI_ABUSE_BOND.md §10): the maker posts a bond before the order is published, gated by `enabled && apply_to ∈ { make, both }`. Lifecycle (maker-specific): - `publish_order` parks a non-range order at `Status::WaitingMakerBond` with NO NIP-33 event, requests the maker bond, and defers the publication. `finalize_order_publication` is factored out and shared by the no-bond inline path and the deferred resume path. - `request_maker_bond` mints the hold invoice, persists a singleton `Bond` row, arms the subscriber, and ships the bolt11 as `Action::PayBondInvoice`. Notional is the fixed sats `amount`, or the price-converted fiat amount for a market-priced single order (one-time snapshot, not repriced — §10.3). Range makers are deferred to Phase 6. - `on_maker_bond_accepted` does a plain `Requested → Locked` CAS (no first-to-lock race — the maker bond is a singleton), idempotent across LND redelivery and the restart resubscriber, then resumes the deferred publication via `resume_publish_after_maker_bond` only while the order is still `WaitingMakerBond`. Slash + release hooks (reused unchanged from Phase 2/4): - Dispute slash resolves to the maker bond by pubkey (sell-order → `slash_seller` targets maker; buy-order → `slash_buyer`), orthogonally to settle/cancel. - Release on every existing exit is role-agnostic, so the maker bond is released on completion, cancel, and expiry. - `WaitingMakerBond` orders expire via the scheduler, marked `Expired` directly in the DB without a NIP-33 republish (the order never appeared in the book — no ghost entry, §10.4) and any bond row released. - `nip33::create_status_tags` emits no event for `WaitingMakerBond`. - Take handlers gate on the new `trade_committed_by_locked_taker_bond` helper (extracted from the duplicated inline check) so a `Locked` maker bond — the steady state under `apply_to = both` — does not block takers with `PendingOrderExists`. Out of scope (deferred per §4): range maker orders (Phase 6) and maker timeout slash (Phase 7); the timeout gate stays `applies_to_taker()`. Bumps mostro-core to 0.12.1 (ships `Status::WaitingMakerBond`). That release also carries the Cashu F1 escrow fields on the `Order` model, so the 20260530120000_cashu_escrow_fields.sql migration is included to keep `Order::by_id` SELECT consistent with the new columns. Tests: maker bond singleton lock + idempotency, lock isolation, dispute slash on both order kinds, `WaitingMakerBond` not published on wire, expiry inclusion in `find_order_by_date`, maker-bond notional, and the locked-maker-bond-does-not-commit-the-trade gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): scope taker-flow bond checks to role='taker' under apply_to=both Addresses review P1: under `apply_to = both` a `Locked` maker bond is present on every published order, and three taker-flow checks counted it as if it were a competing taker bond — breaking the taker flow whenever the maker side is also bonded: 1. `on_bond_invoice_accepted` first-to-lock-wins CAS: the `NOT EXISTS` guard matched any `Locked` bond, so the first taker to pay saw the maker bond, its UPDATE affected zero rows, and the taker was wrongly treated as a race loser (bond released, take cancelled). Every taker on a maker-bonded order was rejected. Guard now filters `role='taker'`. 2. `maybe_drop_waiting_taker_bond`: the "no active bonds remain → drop to Pending" CAS counted the maker bond, so a `WaitingTakerBond` order whose last taker bond was cancelled never returned to `Pending`. Active-bond check now scoped to `role='taker'`. 3. `cancel_order_by_taker` `others_remain`: the lingering maker bond (pubkey != cancelling taker) looked like "another taker still racing", so a lone taker's self-cancel never reset the order. Now counts only taker bonds. The maker bond stays `Locked` and untouched throughout the taker flow; `edit_pubkeys_order` only clears the taker side, so the maker pubkey the bond's slash resolution relies on is preserved across a taker reset. Tests: taker wins the lock race with a `Locked` maker bond present; `WaitingTakerBond` drops to `Pending` ignoring the maker bond. The race test's local CAS mirror is hoisted to a shared `try_lock` helper kept in lockstep with the production query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(bond): testable drop-to-pending helper + range-maker skip log Addresses self-review follow-ups on the Phase 5 PR: - Extract the `WaitingTakerBond → Pending` CAS out of `maybe_drop_waiting_taker_bond` into `drop_waiting_taker_bond_to_pending`, a side-effect-free helper returning whether the transition applied. The wrapper keeps the NIP-33 republish (which needs process-wide keys). The regression test now exercises the real helper instead of an inlined SQL copy that could drift from production, and a new test asserts an active *taker* bond still pins the order (the role filter must not over-drop). - Log a `warn!` when a range order is published under `apply_to ∈ { make, both }` without a maker bond (range-maker bonds are Phase 6). Without it an operator could wrongly assume every order on a bond-enabled node is bonded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(bond): correct cashu-fields version reference to mostro-core 0.12.1 The migration header and two test-setup comments referenced "mostro-core 0.12.0", but the project pins 0.12.1 in Cargo.toml. Align the comments with the pinned version for consistency. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): cleanup stranded maker order + CAS deferred publish Address two races flagged in review of Phase 5 maker bond. - publish_order: if request_maker_bond fails after the order row is parked at WaitingMakerBond, delete the stranded row (scoped to that status) instead of leaving a hidden order until the expiry job reaps it hours later. The order never emitted a NIP-33 event and any bond row was already released, so deletion is safe; the error is surfaced to the maker. - resume_publish_after_maker_bond: guard the deferred publish with an atomic WaitingMakerBond -> Pending compare-and-swap. The subscriber's prior status re-read is not atomic with the publish, so the expiry job could flip the row to Expired (and cancel the locked bond) in between; the full-row write in finalize_order_publication would then resurrect the dead order. On rows_affected != 1 we skip cleanly, mirroring the existing taker-side CAS pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): retain maker bond when take timeout republishes order When a take attempt times out in a waiting state and the order is republished to the book (the taker is the responsible party — WaitingBuyerInvoice/sell, WaitingPayment/buy), the maker is still committed to the order. The scheduler's timeout path was releasing the maker's Locked bond via apply_bond_resolution, leaving a takeable order in the book with no maker bond backing it. slash_or_release_on_timeout now distinguishes republish from terminal cancel: on republish only the abandoning taker bond is resolved (slashed or released) and maker bonds are retained Locked, to be released only when the order itself terminates. Adds release_taker_bonds_for_order_or_warn and routes the slash path through slash_one directly so non-slashed maker bonds survive a republish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a table covering all Nostr event kinds used by the daemon\n- Clarify that kinds 1 and 13 live only inside GiftWrap transport\n- Fix README examples that incorrectly labeled the Mostro info kind as 38383 Co-authored-by: Hermemes <hermemes@users.noreply.github.com>
…P#753) * feat(price): Phase 1 — Yadio provider + PriceManager wiring (single-source 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> * fix(price): track real contributors, one-shot stale warning, scheduler outage log Addresses the three review findings on MostroP2P#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> * fix(price): track outlier-surviving contributors through aggregate_tick Addresses the Hermes review on MostroP2P#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> * test(price): pin exact contributor list in bimodal-fallback test 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> * fix(price): make legacy bitcoin_price_api_url optional + unify live Yadio URL Human-review follow-ups on Phase 1 (spec §10.1 backward compatibility): - types.rs: add #[serde(default)] to bitcoin_price_api_url so a settings.toml that has migrated to a [price] block may omit the deprecated key instead of failing deserialization and aborting startup (arkanoider, codaMW). Field stays — util.rs and install_price_manager() still read it until Phase 4/5. - settings.tpl.toml: document the key's full lifecycle (now optional, still read by the live /convert path until Phase 4, removed in Phase 5). - util.rs: route the live /convert + /currencies path through a yadio_base_url() helper that prefers [price.providers.yadio].url when a [price] block is present, falling back to the legacy key. Stops the live and cached paths silently hitting different Yadio bases when only the new key is customised. - config.rs / manager.rs: point the three [price] validation errors at docs/PRICE_PROVIDERS.md §7 and make the unimplemented-provider message actionable. - tests: prove a [mostro] block without bitcoin_price_api_url deserializes to the default URL, and that legacy synthesis is identical whether the key is present-at-default or omitted. Does not touch price/aggregate.rs, price/store.rs, or the scheduler tick (§5.4 extension-contract invariant holds). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(price): gate + normalize the live Yadio base URL ermeme review on PR MostroP2P#753: the live-path Yadio URL helper could emit broken URLs. It only `trim()`med (not `trim_end_matches('/')` like `YadioProvider::new`), so a configured trailing slash produced `//convert` / `//currencies` and diverged from the aggregate path the helper exists to match. It also used the `[price.providers.yadio]` URL even when the provider was disabled, overriding the legacy `bitcoin_price_api_url` fallback. - Normalize the chosen URL with `trim_end_matches('/')`, matching `YadioProvider::new` (applied to the legacy fallback too). - Use the provider URL only when *usable*: enabled and non-empty after normalization; otherwise fall back to the legacy key. Split the selection out into a pure `select_yadio_base_url` so it is unit testable without the write-once global `Settings`; add tests for the prefer-enabled, strip-trailing-slash, and fall-back-when-unusable cases. (The review's "empty config suppresses the fallback" point did not hold — the existing `!url.is_empty()` guard already fell through — but the trailing-slash and disabled-provider issues were real.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MostroP2P#770) * feat(bond): Phase 6 — range-order maker bond with proportional slashes Range maker orders now post a single anti-abuse bond, sized against `max_amount`, that is slashed proportionally per taken slice and settled once at range close (Option A — accumulate-and-settle-at-close, no liquidity fronting). Daemon-only: reuses the Phase 2 dispute, Phase 3 payout and Phase 5 maker mechanisms; no `mostro-core` change and no new migration (the Phase 0 schema already carries the parent/child columns). - publish_order: size the maker bond against `max_amount` for range orders (was deferred in Phase 5); `maker_bond_notional_sats` gains a range branch. - The maker bond lives on the range *root*; a slash on any slice walks `range_parent_id` to it (`find_maker_bond_for_order`). A maker slice slash inserts a child row (`PendingPayout`) and accumulates `slashed_share_sats` WITHOUT settling the parent HTLC. - Slash share is price-invariant — `slice.fiat_amount / root.max_amount` (both fiat) — so no `parent_max_sats` column is needed; clamped to the bond amount. - Phase 3 payout scheduler skips child rows while the parent is `Locked` (`child_payout_blocked_by_locked_parent`); `resolve_payout_recipient` pays the maker directly for the unslashed-remainder refund row. - resolve_range_maker_bond_at_close: at range close settle the parent once (→ `Slashed`), pay each child counterparty, refund the unslashed remainder to the maker; release (cancel HTLC) when no slice was slashed. Idempotent CAS. Wired into release_action (no remainder), admin_settle/admin_cancel, the cancel.rs termination paths, and the scheduler's pending-expiry. - Tests: proportional/clamped slice slash, settle-once + refund at close, no-slash release, apply records child without settling, chain-walk from a descendant slice, payout parent-locked guard, refund/slice recipient resolution. cargo test/clippy/fmt green (399 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(bond): reference PR MostroP2P#770 for Phase 6 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): address Phase 6 review — claim-window anchor, max guard, close-on-err - payout/close: anchor each slice child's payout claim window at range *close* time (re-stamp slashed_at in resolve_range_maker_bond_at_close), not at slice-slash time, so a long-open range can't forfeit a child the instant it becomes payable. The refund row already used close time. - util: maker_bond_notional_sats rejects a non-positive max_amount for range orders (is_range_order only checks Some-ness), matching the guard in record_maker_slice_slash; prevents a divide-by-zero in the slash. - release: on get_child_order error, resolve the maker bond at close instead of leaving it Locked until LND CLTV — get_child_order only computes (no child is persisted/published on error), so no remainder exists. - tests: range-order bond sizing (success conversion via a new cfg(test) price seeder + non-positive-max rejection) and a close re-anchor test. Skipped (not valid against current code): - "gate Expired commit on close success" — contradicts the best-effort bond design (spec §8.2): bond resolution must never block order lifecycle; gating on LND availability would strand orders Pending. - release.rs retry infrastructure for get_child_order — over-scoped; mostro retries child-order creation nowhere (pre-existing limitation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): Phase 6 review round 2 — idempotent slice slash + structured log - slash: make record_maker_slice_slash idempotent per (parent_bond_id, child_order_id) — guard against inserting a second slash row for the same slice, which the close path would double-count and over-slash/ over-pay. The admin handlers already block a retry via their order- status guard (status moves off Dispute before apply_bond_resolution), so this is defensive and keeps the "one slash row per slice" invariant true for any future caller (e.g. the Phase 7 maker timeout slash). No migration; bounded by per-order admin serialization. - release: use structured tracing fields (order_id, error) for the get_child_order failure warning instead of an interpolated string. - tests: record_maker_slice_slash_is_idempotent_per_slice; the clamp test now uses two distinct slice orders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): make range slice-slash allocation atomic The per-(parent_bond_id, child_order_id) idempotency check in record_maker_slice_slash was a read-then-create, i.e. TOCTOU. admin settle/cancel has two independent entry points — the serial Nostr loop and the RPC service, each with its own LND client — and admin_cancel has no order-status CAS (unlike admin_settle), so two concurrent duplicate cancels could both pass the existence check and both insert a PendingPayout child against the same single HTLC, over-allocating the parent at close. Replace the check + create_bond with a single atomic `INSERT ... WHERE NOT EXISTS`, which is atomic under SQLite's write lock: the loser sees rows_affected = 0 and skips. No migration; the parent's slashed_share_sats recompute already derives from the actual child rows so it stays correct. The existing idempotency test now exercises this path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bond): Phase 6 hardening — unique index + stranded-close reconciliation Defense-in-depth follow-ups on the range-order maker bond: - Add a partial UNIQUE index on bonds(parent_bond_id, child_order_id) so the "one slash row per slice" invariant holds at the schema level for any future caller, not just record_maker_slice_slash's atomic INSERT ... WHERE NOT EXISTS. Treat a constraint violation as the same idempotent no-op (sqlx 0.6 has no is_unique_violation; match on the extended code / message). - Add a periodic scheduler sweep (job_reconcile_stranded_maker_bonds) that retries resolve_range_maker_bond_at_close for any maker bond left Locked after a transient close failure, once its whole range tree is terminal. The close is idempotent (CAS), so a close failure no longer relies solely on the CLTV safety net. Open ranges are never touched. - Add a wallet-accounting invariant test: across 2+ slashed slices the child rows (slices + maker refund) sum to the parent bond exactly and the refund absorbs any rounding remainder — no sat created or lost. - Document both in docs/ANTI_ABUSE_BOND.md §11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): cycle-safe range-tree walk + per-root sweep isolation Addresses CodeRabbit review 4476618573 plus a related robustness follow-up: - db.rs: the recursive CTE in `range_tree_fully_terminal` walked `range_parent_id` downward with `UNION ALL` and no cycle/depth guard, so a corrupt cycle could loop unbounded and hang the scheduler tick. Switch to `UNION` (dedup breaks any cycle); the result is only reduced to `COUNT(*) == 0`, so dedup doesn't change the semantics. Mirrors the `MAX_RANGE_CHAIN_DEPTH` guard on the upward walk. - slash.rs: `collect_stranded_range_maker_roots` propagated per-root DB errors via `?`, so one bad root aborted the whole reconciliation tick and starved retries for every other stranded bond. Log + `continue` per root instead (best-effort, §8.2). - Tests: cycle-safe tree walk (A↔B returns promptly), and per-root isolation (a bad/orphaned root is skipped while a valid stranded root still resolves). - docs §11: note the sweep is cycle-safe and isolates per-root failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): close range maker bond atomically to kill the partial-close window Addresses ermeme review 4477194281. The close flipped the parent Locked → Slashed via CAS BEFORE the dependent work (maker refund row, child slashed_at re-anchor) was committed. A crash/DB failure after the CAS left a Slashed-but-incomplete parent the Locked-keyed reconciliation sweep never retries — silently dropping the maker refund and forfeiting children on a stale anchor. Eliminate the window instead of repairing it: - Settle the parent HTLC FIRST, while still Locked (already idempotent- tolerant via is_already_settled_error, so a retry re-settles harmlessly). - Then run the CAS, the child re-anchor, and the refund INSERT in ONE sqlx transaction (pool.begin → execute ×3 → commit). The refund row is now a raw INSERT (mirrors record_maker_slice_slash) so it shares the tx. The CAS stays inside the tx: a racing loser sees rows_affected = 0 and rolls back with no side effects. After commit, Slashed always means "close fully done"; Locked is the sole in-flight state, so the existing sweep covers every crash point. No new reconciliation path for partially-closed Slashed parents. Public signatures, the sweep, and the payout scheduler are untouched. Tests: range_close_crash_after_settle_is_resumed (trigger-injected tx failure → parent stays Locked, no refund row, child not re-anchored; retry with "already settled" completes; settle twice, rows once) and settle_already_settled_is_treated_as_success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bond): close the stale-snapshot over-refund race at range close ermeme review on PR MostroP2P#770: `slice_children`/`total_slashed` were read before the close transaction began, so a slice slash committing between that snapshot and the `Locked → Slashed` CAS was missed. The late child still got paid by the scheduler while the maker refund was computed from the stale total, pushing the distributed total (children + refund) past the single settled HTLC. Two-part fix that locks the close against concurrent slice slashes: - Gate the slice-slash INSERT on the parent still being `Locked` (`AND EXISTS (... state = Locked)`). Once the close wins its CAS, no further child can be inserted; a slice that misses the window is dropped (the safe direction — the HTLC amount is already fixed). - Recompute `total_slashed` authoritatively from the child rows *inside* the close transaction (after the CAS holds the write lock), and derive the refund from that instead of the pre-transaction snapshot. Add a regression test asserting a slash is a no-op once the parent has left `Locked`. Existing two-slice conservation test covers the in-tx recompute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stroP2P#770 merge skew (MostroP2P#774) PR MostroP2P#753 removed the BITCOIN_PRICES static when it migrated price reads to the PriceManager, while PR MostroP2P#770 (merged independently) added BitcoinPriceManager::set_price_for_test writing to that static — the combination doesn't compile under cargo test on main. Re-point the test seam at a small cfg(test) override map consulted by price::get_bitcoin_price before the global manager, so unit tests keep seeding deterministic prices without installing the global PriceManager (whose OnceLock would leak one test's configuration into the rest of the binary). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(price): repair test-only price seeding broken by MostroP2P#753/MostroP2P#770 merge skew PR MostroP2P#753 removed the BITCOIN_PRICES static when it migrated price reads to the PriceManager, while PR MostroP2P#770 (merged independently) added BitcoinPriceManager::set_price_for_test writing to that static — the combination doesn't compile under cargo test on main. Re-point the test seam at a small cfg(test) override map consulted by price::get_bitcoin_price before the global manager, so unit tests keep seeding deterministic prices without installing the global PriceManager (whose OnceLock would leak one test's configuration into the rest of the binary). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bond): Phase 7 — maker timeout slash Fill the maker-responsible rows of the §9.2 responsibility table: a waiting-state timeout now slashes the maker's bond when the maker is the responsible party, gated per posting role (apply_to must cover the responsible side — taker since Phase 4, maker since Phase 7). - slash_or_release_on_timeout resolves the responsible bond through the range-aware resolve_slash_target (the maker bond of a range order lives on the range root), and gates the slash on applies_to_maker() / applies_to_taker() per the responsible side. - A maker-responsible timeout on a range order goes through the Phase 6 partial-slash path: record_maker_slice_slash inserts a proportional child row with reason=Timeout and the parent HTLC stays Locked; the single settle happens at range close. The helper now returns whether it inserted, so the BondSlashed notice fires exactly once (a scheduler retry that finds the child already recorded reports None). - The scheduler's terminal cancel branch now runs resolve_range_maker_bond_at_close_or_warn after the Canceled status persists, so a slashed range settles and distributes promptly instead of waiting for the 5-minute reconciliation sweep (which remains the backstop). The republish branch never closes — the maker stays committed there. - The timeout release loop retains a range maker parent bond (resolved only at range close), alongside the existing republish carve-out. Tests mirror Phase 4 from the maker side: non-range sell/buy slashes, the per-role apply_to gate, and the range path (proportional child, parent stays Locked, root resolution from a descendant slice, per-slice idempotency without re-notification). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(bond): mark Phase 7 shipped + implementation notes; fix stale Phase 4.5 row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…2P#777) * docs(bond): Phase 8 — public config exposure + operator docs Final phase of the anti-abuse bond rollout (spec §13). The load-bearing code — the kind-38385 info-event policy tags (`bond_enabled`, `bond_apply_to`, `bond_amount_pct`, …) — already shipped in Phase 3 (MostroP2P#738) with tests, so Phase 8 is documentation polish only. No code, no migration, no mostro-core change. - README: anti-abuse bond feature bullet + a "How It Works" overview covering the maker/taker vs buyer/seller axes and the lock → release → slash lifecycle, plus the info-event exposure. - docs/ARCHITECTURE.md: bond modules in the action map, per-action summaries, the §3.1 axes note, and a Lock → Resolve sequence diagram. - docs/LIGHTNING_OPS.md: operator runbook — bonds-table inspection, the state machine, scheduler jobs, BondResolution wire format, and how to handle a `failed` bond. - docs/README.md: index entry. - docs/ANTI_ABUSE_BOND.md: mark Phase 8 complete; the feature is done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(bond): reference PR MostroP2P#777 in Phase 8 spec status Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(bond): fix payout action in diagram + .env fence language - ARCHITECTURE.md: the winner submits the bond payout bolt11 via Action::AddBondInvoice (app.rs routes it to add_bond_invoice_action), not Action::AddInvoice. Correct the Lock → Resolve sequence diagram. - README.md: add 'bash' language to the MOSTRO_NSEC_PRIVKEY .env fenced block (MD040). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on (MostroP2P#773) * feat(price): Phase 2 — direct backup quoters + multi-source aggregation Implements Phase 2 of docs/PRICE_PROVIDERS.md: the system becomes genuinely multi-source. Three keyless direct (PerBtc) adapters join Yadio, each fixture-tested against a captured live payload (§10.5): - coingecko: GET /simple/price over a baked fiat vs_currencies list; optional demo/pro api_key (header picked from the host), redacted from Debug/logs per §10.3. - currency_api: GET /currencies/btc.min.json; lowercase codes upper-cased (§6.6); implements `fallback_urls` — mirrors tried in order, the provider only fails when every URL fails (§7). Fallback behaviour covered by a local-axum integration test. - blockchain: GET /ticker taking `last` (mid-market) only; buy/sell are not even deserialised (§6.6/§11.6). Manager (spec §5.3, §6.5, §6.6): - Providers are polled concurrently (join_all), each fetch bounded by provider_timeout_seconds — tick wall-clock = slowest provider, not the sum. - Circuit breaker wired: Phase 0's ProviderHealth now gates polling; cooldown-skipped providers land in the new TickReport.skipped (neither success nor failure). Backoff cap fixed at 1800s (§6.5). - §6.6 fiat allowlist (new price::fiat): ISO-4217 active codes plus the non-ISO fiat Yadio really trades (IRT, GGP/IMP/JEP, MLC, XCG). Against the live Yadio feed this drops exactly BTC/XAU/XAG/XPT. VEF is deliberately excluded (pre-redenomination unit vs VES); the CoinGecko request list omits it for the same reason. Phase 2 acceptance tests (§9): median+outlier across 4 providers, lowercase/uppercase combine, official-CUP scoped out by `except` (2-source case the outlier guard can't save), non-fiat dropped by allowlist, provider-down fallback, breaker open-skip and reset-after-success. 431 tests green. Config: settings.tpl.toml gains the three §7 provider blocks (currency_api ships except = ["CUP", "MLC"]). El Toque remains rejected at startup until Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(price): cover the full mirror sequence in the poll budget + fresh breaker timestamps Address PR MostroP2P#773 review findings: - Codex P2 (valid): the outer per-provider timeout equalled the reqwest per-request timeout, so a *hanging* currency_api primary consumed the whole budget and the fallback_urls were dead code in exactly the hung case (they only worked for fast failures like connection-refused). New `poll_budget(id)` sizes the outer tokio timeout to provider_timeout_seconds × (1 + fallback_urls) + 1s slack; the per-attempt bound remains the shared reqwest client's request timeout. Adds a manager unit test for the scaling and an adapter-level axum test proving a hung primary is cut at the request timeout and the mirror still answers. - CodeRabbit Major (valid, minor impact): record_failure reused the pre-poll `now`, so breaker cooldowns were born already aged by up to a full poll budget. Failures are now stamped after polling completes. - CodeRabbit nit (declined): tracing spans around the polling — the codebase uses zero spans anywhere; per-provider outcomes already log the provider id inline, so spans here would be inconsistent house style for little correlation gain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rod (MostroP2P#776) * feat(transport): Phase 1 — wire protocol v2 (NIP-44 direct) into mostrod Phase 1 of the Messaging Transport Abstraction Layer (MostroP2P#626), on top of mostro-core 0.13.0 (Phase 0, MostroP2P/mostro-core#152). The node now speaks the operator-configured transport — protocol v1 gift wraps (kind 1059, DEPRECATED) or protocol v2 signed kind-14 events with NIP-44 encrypted content — with zero handler changes: both transports unwrap into the same UnwrappedMessage via mostro-core's kind dispatch. - Bump mostro-core 0.12.1 -> 0.13.0 (protocol v2 transport module, PROTOCOL_VER = 2). - New `[mostro] transport` setting ("gift-wrap" | "nip44", serde default gift-wrap so existing settings.toml files keep working and the wire behavior is identical to pre-v2 daemons). - New `[expiration] dm_days` knob (default 30): kind-14 events always carry a NIP-40 expiration tag; send_dm() fills it on the nip44 transport when the caller didn't pass one. - main.rs subscribes to the configured transport's kind only; app.rs accepts only that kind and unwraps via unwrap_incoming(). - Kind-38385 info event advertises `protocol_versions` ("1" or "2") so clients pick the right wire format before sending. - docs/TRANSPORT_V2_SPEC.md: full context, v2 wire format (including the trade-key-bound identity proof), versioning, operator config, release timeline (v1 DEPRECATED in 0.18.0, removed in 0.19.0) and the phase guide (Phase 2 anti-spam gates, Phase 3 protocol docs, Phase 4 cutover). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transport): non-panicking transport accessor + MD040 fence Codex P2: send_dm() called Settings::get_mostro(), which panics when the global MOSTRO_CONFIG isn't initialized — turning the previously fallible call into an abort in unit tests that don't bring up the full configuration (test_send_dm only survived by test-ordering luck). Add Settings::get_transport(), which falls back to the gift-wrap default when settings are absent, mirroring the existing get_bond / get_price non-panicking accessors. The nip44 expiration path is unreachable without initialized settings (the fallback is gift-wrap), so no other panic path is introduced. Note: test_send_dm's assertion was already order-dependent on main (it expects Err, which only happens when another test has installed NOSTR_CLIENT) — unchanged by this PR. Also tag the identity-proof payload fence in the SPEC as `text` (markdownlint MD040, flagged by Codex and CodeRabbit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…stroP2P#779) * refactor(bond): generalize slash confirmation witness - Extract slash_reason_recorded(pool, bond_id, expected) from timeout_slash_confirmed - timeout_slash_confirmed now delegates to it keyed on BondSlashReason::Timeout - Pure refactor, no behavior change * feat(bond): notify slashed party on dispute slash (MostroP2P#768) - apply_bond_resolution returns the confirmed-slashed bond rows - non-range/taker rows confirmed via slash_reason_recorded(LostDispute) - range maker rows returned only when the slice child row is newly inserted - admin_settle/admin_cancel send a best-effort BondSlashed notice per row - transient settle failures and idempotent retries yield no row, so a notice is never untruthful and a winner is never re-notified * test(bond): cover dispute-slash forfeiture notice - apply_bond_resolution returns one confirmed row per slashed side - both-sides slash returns both rows; null payload returns none - transient settle failure returns no row (notice never untruthful) - non-range and range retries return no row (winner never re-notified) - range slash returns the child slice row with the slice amount * docs(bond): dispute slash sends BondSlashed
…#780) Implements Phase 2 of docs/TRANSPORT_V2_SPEC.md (§6, issue MostroP2P#626): the payoff of the kind-14 transport — reject junk BEFORE paying the NIP-44 decrypt cost. v2-only; the gift-wrap (v1) path is untouched (its outer key is a throwaway with no pre-validatable signal). Zero handler changes — the gate lives entirely in the event-loop preamble. Design (confirmed with maintainer): - Dedicated `pow_first_contact` knob: known active-trade keys need only the base `pow`; unseen (first-contact) senders must clear `pow_first_contact` before decryption. Defaults to `pow`, so existing configs are wire-identical. - Active-trade-pubkey cache refreshed by a periodic scheduler job (status mutations are scattered with no choke-point, so a full reload is the robust, low-coupling strategy) + warmed at startup. Changes: - src/spam_gate.rs (new): `SpamGate` global (OnceLock, mirrors PriceManager) — known-keys cache (RwLock<HashSet>) + a REPLAY_WINDOW_SECS (60s) replay guard for dedup. Unit-tested (membership/replace, replay dedup, window expiry, prune bound). - src/db.rs: `find_active_trade_pubkeys` — buyer/seller/creator of every non-terminal order + solver of every active dispute. New TERMINAL_ORDER_STATUSES = EXCLUDED_ORDER_STATUSES minus 'dispute' (a disputed order is still active). DB test covers the active/disputed/terminal nuance. - src/app.rs: the gate in the event loop (v2 only) — dedup replay drop, then known-keys fast-path vs first-contact `pow_first_contact` gate, all before unwrap_incoming. - src/config/types.rs: `[mostro] pow_first_contact` (Option<u8>) + `active_pubkeys_refresh_interval` (u64, default 60), both #[serde(default)]; `effective_pow_first_contact()` accessor. Tests incl. legacy-config parse. - src/scheduler.rs: `job_refresh_active_pubkeys`. - src/main.rs: install + warm the gate before the event loop. - settings.tpl.toml + docs/TRANSPORT_V2_SPEC.md: document the new knobs and mark Phase 2 done. Tests: 482 passed; cargo clippy --all-targets --all-features -D warnings clean; cargo fmt clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit an info line on boot showing the configured transport name, protocol version, and event kind so operators can confirm at a glance whether the node speaks gift-wrap (v1) or nip44 (v2). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stroP2P#782) Client and node are compatible only when they run the same protocol version, so the kind-38385 info tag carries a single value ("1" or "2"), not a list. Rename the emitted tag (and docs) to the singular form to match the protocol spec and the mostro-cli reader. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…P2P#778) * feat(price): Phase 3 — El Toque fiat-cross provider (CUP/MLC), provisional wiring Implements Phase 3 of docs/PRICE_PROVIDERS.md (§9, §11.3): the El Toque provider, a fiat-cross quoter that contributes CUP and MLC as `Quote::PerBase { base: "USD" }`, resolved against the aggregated USD/BTC anchor by Phase 0's existing `resolve_per_base` (no aggregation-core change). Per §5.4 this is one adapter file + one registry arm + one config block + one fixture — the aggregation core, store, scheduler and handlers are untouched. - src/price/providers/eltoque.rs: ElToqueProvider. Parses El Toque's CUP-denominated `tasas` payload and emits: CUP -> PerBase{USD, cup_per_usd} MLC -> PerBase{USD, cup_per_usd / cup_per_mlc} (§11.3 cross math) Bearer-token auth (required when enabled -> startup error otherwise, §7); token redacted from Debug/logs (§10.3). Anchor = USD only (the §11.3 EUR-fallback question was declined for this phase). - manager.rs: registry arm builds El Toque (fails fast without a token); the old "unimplemented" rejection and its test are replaced with with-token / without-token coverage. - settings.tpl.toml: real [price.providers.eltoque] block (enabled=false). - docs/PRICE_PROVIDERS.md: Phase 3 marked in review; §11.3 status note. PROVISIONAL: the tasas API is token-gated, so a real payload could not be captured. The parse path is grounded in the confirmed CUP-denominated `tasas` shape and is fully unit-tested; the request line in `fetch` (path /v1/trmi, GET, no date params) is best-effort from third-party reverse-engineering and the shipped fixture is a representative sample, not a capture. All clearly flagged in comments and the spec — to be finalised against the official docs in a follow-up. Keep enabled=false in production until then. Tests: 8 new El Toque adapter tests + updated registry tests. Full suite 472 passed; cargo clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(price): Phase 3 — confirm El Toque request line and capture fixture Apply the El Toque API details confirmed with the maintainer, finalising the previously-provisional request wiring: - fetch now sends the required [date_from, date_to] range: GET /v1/trmi?date_from=…&date_to=… (YYYY-MM-DD HH:MM:SS, URL-encoded by reqwest), Bearer auth unchanged. The endpoint returns the most recent rate within the range, so we query a rolling 48h window ending "now" (LOOKBACK_HOURS) — wide enough to absorb the ~daily TRMI update cadence and UTC↔Cuba skew without returning empty tasas. - tests/fixtures/price/eltoque_trmi.json: replaced the representative sample with a real captured response. This also corrects the timestamp shape — hour/minutes/seconds are separate integers, not a "HH:MM:SS" string (the parser ignores them either way). - Dropped the PROVISIONAL/reverse-engineering language from the module doc and docs/PRICE_PROVIDERS.md; documented the confirmed request and response shapes. Tests updated to the captured values (USD=490, MLC=200 → MLC/USD = 490/200). Q1/Q3 (§11.3 maintainer questions) remain open; provider stays enabled=false until a token is provisioned. Tests: full price suite 91 passed; cargo clippy --all-targets + fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(price): El Toque window must be under 24h (was 48h → 400) The /v1/trmi endpoint rejects any [date_from, date_to] range of 24h or more with `400 "El intervalo de tiempo debe ser menor a 24 horas"`, so every poll failed regardless of token. Lower LOOKBACK_HOURS from 48 to 23 (1h margin under the hard cap, still spanning a full daily TRMI cycle) and document the constraint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NvSncWrjedLiAtAV4DTJR * feat(price): warn when legacy bitcoin_price_api_url is ignored When `[price]` is configured the multi-source manager drives aggregation and the legacy `[mostro].bitcoin_price_api_url` is not consulted. Emit a startup WARN naming the legacy value and the enabled providers so an operator who still has the legacy key set isn't misled into thinking it takes effect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NvSncWrjedLiAtAV4DTJR --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#752) * fix: return CantDo(NotFound) for missing orders instead of InvalidOrderId * fix: format order_id extraction in get_order (no functional change)
…rt (MostroP2P#785) `MessageKind::new` always stamps the crate-wide `PROTOCOL_VER` (= 2), so every server reply advertised protocol v2 even when served over the v1 gift-wrap transport. Stamp the inner message version in `send_dm` — the single wrap chokepoint — so it follows the negotiated wire format: `gift-wrap` -> v1, `nip44` -> v2. Done before wrapping so the version is covered by the message/trade signatures. Logic extracted into `stamp_protocol_version` for unit testing. Claude-Session: https://claude.ai/code/session_0126WFgFsxsvCewFb9pxfT7v Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ain (MostroP2P#787) Full mutation testing is slow (one test-suite run per mutant) and the job is `continue-on-error`, so running the baseline on every push to `main` gated nothing and only burned CI minutes. Drop the `push: [main]` trigger; the baseline now runs weekly (`schedule`) and on demand (`workflow_dispatch`), alongside the existing opt-in per-PR job (`run-mutation` label). Also swap `cargo install cargo-mutants` (compiles from source on every run) for `taiki-e/install-action` (prebuilt binaries) in both jobs. Docs updated to describe the new triggers and point to `cargo mutants --in-diff` for pre-push feedback. Claude-Session: https://claude.ai/code/session_0126WFgFsxsvCewFb9pxfT7v Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback on the Cashu Track B release path. CI: - Remove unused `mint_url` field and three useless `cdk::Error` conversions in src/cashu/mod.rs (clippy -D warnings) and apply rustfmt. Release path (src/app/release.rs): - Compute and validate the P_M proof signatures before mutating any state. If token parsing, key derivation or signing fails, or no proofs are produced, return an error and leave the order in FiatSent/Dispute so the release stays retryable instead of marking it Success with the buyer unable to redeem the escrow. - Replace the unconditional unwrap() on cashu_escrow_token with a typed error. - Skip the Lightning-only Released and HoldInvoicePaymentSettled notifications in Cashu mode. Other: - escrow.rs: CashuBackend stubs return typed errors instead of unimplemented!() so an accidental instantiation cannot panic the daemon. - cashu/mod.rs: drop the never-read CASHU_STATUS global (connect() already reports failure via Err) and implement std::error::Error for cashu::Error. - db.rs: bind Status::Active in find_locked_cashu_orders instead of a literal. - sqlx-data.json: add trailing newline. - docker-compose.cashu.yml: document MINT_PRIVATE_KEY as a test-only fixture.
|
Hi @a1denvalu3 — thanks for this work! I pushed a commit directly to the branch ( What I changed CI was red, so first the blockers:
Then hardening of the release happy path:
Verified locally: Left for you / a follow-up (not addressed here) The biggest one is a security gap I intentionally did not touch: the release path never re-verifies the escrow token against the mint. Smaller items also left as-is: |
# Conflicts: # Cargo.lock # Cargo.toml # migrations/20260530120000_cashu_escrow_fields.sql # src/app/bond/flow.rs # src/app/bond/payout.rs # src/app/bond/slash.rs # src/main.rs
This PR implements Track B of the Cashu 2-of-3 escrow architecture described in
docs/CASHU_ESCROW_ARCHITECTURE.md.In Cashu mode, the Mostro daemon does not hold custody of funds. Therefore, in the happy path release flow, Mostro simply needs to transition the order state to
Successand bypass the Lightning hold invoice settlement.The cryptographic signatures for the release are sent out-of-band directly from the seller to the buyer via Nostr DM.
Successinstead ofSettledHoldInvoice.PurchaseCompletedmessage to the buyer immediately.Summary by CodeRabbit
Release Notes