From bc24a88e803ce7d5bc79f839eb0575f003df0bbc Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Sat, 30 May 2026 16:35:24 +0200 Subject: [PATCH 01/23] feat(cashu): implement Track B - release happy path 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. --- src/app/release.rs | 113 +++++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/src/app/release.rs b/src/app/release.rs index f52ac3d9..a29c450e 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -1,6 +1,7 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dispute::close_dispute_after_user_resolution; +use crate::config::settings::Settings; use crate::escrow::EscrowBackend; use crate::lightning::LndConnector; use crate::lnurl::resolv_ln_address; @@ -189,35 +190,87 @@ pub async fn release_action( .get_next_trade_key() .map_err(MostroInternalErr)?; - // Settle seller hold invoice - settle_seller_hold_invoice(event, ln_client, Action::Released, false, &order).await?; - // Update order event with status SettledHoldInvoice - order = update_order_event(my_keys, Status::SettledHoldInvoice, &order) - .await - .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; - - // Persist the status change to DB before calling do_payment. - // do_payment spawns async tasks that capture an Order copy; without this - // explicit write the settled-hold-invoice status only lived in memory and - // was persisted as a side-effect of the full-row writes in - // check_failure_retries / payment_success (now replaced by targeted updates). - let result = - sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") - .bind(&order.status) - .bind(&order.event_id) - .bind(order.id) - .bind(Status::FiatSent.to_string()) - .bind(Status::Dispute.to_string()) - .execute(pool) + let is_cashu = Settings::is_cashu_enabled() && order.cashu_escrow_token.is_some(); + + if is_cashu { + // Cashu flow: skip lightning invoice settlement and go straight to Success. + // Update order event with status Success + order = update_order_event(my_keys, Status::Success, &order) .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + let result = + sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 0 { + tracing::warn!( + "Order {} not transitioned to success: status changed concurrently", + order.id + ); + return Ok(()); + } + + // Send PurchaseCompleted message to buyer + enqueue_order_msg( + None, + Some(order.id), + Action::PurchaseCompleted, + None, + buyer_pubkey, + None, + ) + .await; - if result.rows_affected() == 0 { - tracing::warn!( - "Order {} not transitioned to settled-hold-invoice: status changed concurrently", - order.id - ); - return Ok(()); + // Send dm to buyer to rate counterpart + enqueue_order_msg( + request_id, + Some(order.id), + Action::Rate, + None, + buyer_pubkey, + None, + ) + .await; + } else { + // Lightning flow + // Settle seller hold invoice + settle_seller_hold_invoice(event, ln_client, Action::Released, false, &order).await?; + // Update order event with status SettledHoldInvoice + order = update_order_event(my_keys, Status::SettledHoldInvoice, &order) + .await + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + // Persist the status change to DB before calling do_payment. + // do_payment spawns async tasks that capture an Order copy; without this + // explicit write the settled-hold-invoice status only lived in memory and + // was persisted as a side-effect of the full-row writes in + // check_failure_retries / payment_success (now replaced by targeted updates). + let result = + sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 0 { + tracing::warn!( + "Order {} not transitioned to settled-hold-invoice: status changed concurrently", + order.id + ); + return Ok(()); + } } // If there was an active dispute on this order, close it since the seller @@ -276,8 +329,10 @@ pub async fn release_action( // does not block trade finalization. bond::release_bonds_for_order_or_warn(pool, order.id, "release_action").await; - // Finally we try to pay buyer's invoice - let _ = do_payment(ctx, order, request_id).await; + if !is_cashu { + // Finally we try to pay buyer's invoice + let _ = do_payment(ctx, order, request_id).await; + } Ok(()) } From 924f1b62ec232560b09cf79abb8c0e08844dad20 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Sat, 30 May 2026 16:52:28 +0200 Subject: [PATCH 02/23] feat(cashu): send P_M signature to buyer on release 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. --- src/app/release.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/app/release.rs b/src/app/release.rs index a29c450e..b6ae4839 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -229,6 +229,36 @@ pub async fn release_action( ) .await; + // Generate and send PM signatures to the buyer "just in case" the seller forgot + // to send their own signature to the buyer via NIP-59 DM. + 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; + } + // Send dm to buyer to rate counterpart enqueue_order_msg( request_id, From bab00e46df53addf942c1ed4bbe654fbba8b8d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Tue, 2 Jun 2026 04:07:05 -0300 Subject: [PATCH 03/23] =?UTF-8?q?feat(bond):=20Phase=204.5=20=E2=80=94=20r?= =?UTF-8?q?e-prompt=20winner=20for=20payout=20invoice=20on=20payment=20fai?= =?UTF-8?q?lure=20(#755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 #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 #750 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(bond): guard re-prompt against double payout on indeterminate failures Codex review on #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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/ANTI_ABUSE_BOND.md | 277 ++++++++++++++++++++-- src/app/bond/payout.rs | 513 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 722 insertions(+), 68 deletions(-) diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index c548dac8..b16cbcca 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -186,11 +186,12 @@ slash path. |------:|----------|------------|--------| | 0 | Foundation: config schema, `bonds` table, pure helpers, types | — | ✅ shipped (PR #712) | | 1 | Taker bond lifecycle: **lock + always release** (no slashing yet) | 0 | ✅ shipped (PR #719) | -| 1.5 | Protocol cleanup: dedicated `Action::PayBondInvoice` + `Status::WaitingTakerBond` (retire the Phase 1 `PayInvoice` reuse) | 1 | pending | -| 2 | Solver-directed dispute slash via `BondResolution` payload (taker bond) | 1.5 | pending | -| 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | pending | -| 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | proposed | -| 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | ✅ shipped (mostro-core 0.11.5) | +| 1.5 | Protocol cleanup: dedicated `Action::PayBondInvoice` + `Status::WaitingTakerBond` (retire the Phase 1 `PayInvoice` reuse) | 1 | ✅ shipped (PR #736) | +| 2 | Solver-directed dispute slash via `BondResolution` payload (taker bond) | 1.5 | ✅ shipped (PR #737) | +| 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | ✅ shipped (PR #738) | +| 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | ✅ shipped (PR #743) | +| 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | ✅ shipped (PR #744) | +| 4.5 | Re-prompt the winner for a fresh payout invoice after `send_payment` retries exhaust, instead of stranding the bond in `Failed` ([issue #750](https://github.com/MostroP2P/mostro/issues/750)) | 3 | pending | | 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | pending | | 6 | Maker bond for **range orders** with proportional slashes | 5 | pending | | 7 | Timeout slash for maker bond | 5 | pending | @@ -199,7 +200,21 @@ slash path. Phases 4, 5, 6, 7 can partially overlap in time but must land in this order on `main` to keep review scope honest. Phase 3.5 depends only on Phase 3 and is orthogonal to the slash-path phases (4–7); it can land -any time after Phase 3. +any time after Phase 3. Phase 4.5 likewise depends only on Phase 3's +payout flow (it hardens the `send_payment`-exhaustion path) and is +orthogonal to the slash-direction phases — it can land any time after +Phase 3, and is numbered 4.5 only because it was reported from field +testing after Phase 4 shipped. + +**Status as of this revision.** Phases 0 through 4 are merged on +`main` (PRs #712, #719, #736, #737, #738, #743, #744). The +`mostro-core` pin in `Cargo.toml` is **0.11.5**, which carries every +protocol variant those phases need (`Status::WaitingTakerBond`, +`Action::PayBondInvoice`, `Payload::BondResolution`, +`Action::AddBondInvoice`, `Payload::BondPayoutRequest`, +`Action::BondInvoiceAccepted`, `Action::BondPayoutCompleted`, +`Action::BondSlashed`). Phase 4.5 and Phases 5–8 are not yet +implemented. --- @@ -519,7 +534,7 @@ instead of memo parsing. --- -## 6.5. Phase 1.5 — Dedicated `PayBondInvoice` + `WaitingTakerBond` +## 6.5. Phase 1.5 — Dedicated `PayBondInvoice` + `WaitingTakerBond` ✅ Completed Small, protocol-only phase. Lands the dedicated `Action` and `Status` variants that Phase 1 deferred (§6 implementation note) so clients can @@ -804,7 +819,7 @@ never has to lean on memo parsing in the wild. --- -## 7. Phase 2 — Solver-directed dispute slash +## 7. Phase 2 — Solver-directed dispute slash ✅ Completed Behaviour gate: `enabled && apply_to ∈ { take, both }` (Phase 5 extends to maker). @@ -977,7 +992,7 @@ sats are already in Mostro's wallet. --- -## 8. Phase 3 — Payout flow +## 8. Phase 3 — Payout flow ✅ Completed Shared infrastructure used by every slash path afterwards. Non-blocking: trade finalization must never wait on the **counterparty** payout @@ -1139,6 +1154,13 @@ must land hand-in-hand with the client adoption. See §14.3. to `PendingPayout`, overwrites `payout_invoice`, and resets `payout_attempts` to 0 (see `add_bond_invoice_action` below). Past the claim window, `Failed` requires operator attention. + **Superseded by Phase 4.5 (§9.5):** as shipped in Phase 3 this + recovery only fires if the winner *spontaneously* resubmits — + Mostro never re-prompts — which makes the in-window recovery + unreachable in practice ([issue #750](https://github.com/MostroP2P/mostro/issues/750)). + Phase 4.5 changes the in-window exhaustion transition to discard + the stale invoice and re-prompt the winner via the scheduler + instead of going straight to `Failed`. When `slash_node_share_pct = 1.0` the counterparty leg is skipped entirely (no `AddBondInvoice` message, no `send_payment`, no forfeit @@ -1230,7 +1252,13 @@ must land hand-in-hand with the client adoption. See §14.3. - **User-side recovery from `Failed`.** `Failed` is *not* a hard terminal state from the recipient's perspective. A fresh `AddBondInvoice` from the same recipient resurrects the bond via a - guarded CAS: + guarded CAS. **(Phase 4.5 §9.5 makes this Mostro-driven.** In Phase 3 + this resurrection depends on the winner spontaneously resubmitting; + Phase 4.5 has the daemon re-prompt the winner on in-window + retry-exhaustion so the recovery no longer hinges on a client guess — + see [issue #750](https://github.com/MostroP2P/mostro/issues/750). The + CAS below is retained as belt-and-braces for rows that still reach + `Failed`.) `UPDATE bonds SET state='pending-payout', payout_invoice=?, payout_attempts=0, invoice_request_attempts=0 WHERE id=? AND state='failed'`, gated in Rust by `now - @@ -1355,7 +1383,7 @@ must land hand-in-hand with the client adoption. See §14.3. --- -## 8.5. Phase 3.5 — Payout confirmation to the winning counterparty +## 8.5. Phase 3.5 — Payout confirmation to the winning counterparty ✅ Completed Small, protocol-only follow-up to Phase 3. Phase 3 drives the payout but tells the winner **nothing** once they have submitted their bolt11: the @@ -1472,7 +1500,7 @@ the client to stop prompting. --- -## 9. Phase 4 — Timeout slash (taker bond) +## 9. Phase 4 — Timeout slash (taker bond) ✅ Completed Gate: `enabled && slash_on_waiting_timeout && apply_to ∈ { take, both }`. @@ -1574,6 +1602,207 @@ slash notice uses `Action::BondSlashed` (mostro-core **0.11.5**). --- +## 9.5. Phase 4.5 — Re-prompt the winner after payout-payment failure + +Small, daemon-only follow-up to Phase 3. No `mostro-core` change, no +schema change, no new slashing — it closes a hole in the Phase 3 payout +state machine that makes a slashed bond's counterparty share +unrecoverable in practice. Reported as +[issue #750](https://github.com/MostroP2P/mostro/issues/750) from field +testing of the bond rollout. + +Depends only on Phase 3 (it reuses `Action::AddBondInvoice` from Phase 3 +and `Action::BondInvoiceAccepted` from Phase 3.5); orthogonal to the +slash-direction phases (4–7). + +### 9.5.1 The problem + +Once a bond is slashed and the winning counterparty has submitted a +payout bolt11, Phase 3's scheduler (`run_bond_payout_cycle` → +`process_one_bond` → `pay_counterparty`) tries `send_payment` against +that invoice and, on failure, bumps `payout_attempts` +(`on_send_payment_failure` in `src/app/bond/payout.rs`). When +`payout_attempts >= payout_max_retries` (default 5) the bond transitions +to `Failed` and **only an ERROR is logged — no message is sent to the +winner**. + +From that point the counterparty share is stranded: + +1. The scheduler enumerates **only `PendingPayout` bonds**. A `Failed` + bond is invisible to it, so `Action::AddBondInvoice` is never + re-sent. +2. Throughout the retry phase the row keeps its original + `payout_invoice` set, so `process_one_bond` always routes to + `pay_counterparty`. All `payout_max_retries` attempts hit the **same** + bolt11 — Mostro never asks the winner for a fresh one (e.g. routed via + a different path or a node with inbound liquidity). + +Phase 3 documented a recovery path (the "`Failed` resurrection" branch in +`apply_payout_invoice`, §8.2): a fresh `AddBondInvoice` from the winner, +inside the claim window, flips `Failed → PendingPayout`, overwrites +`payout_invoice`, and resets `payout_attempts`. But that branch only +fires if the **client spontaneously resubmits**. Mostro never prompts the +winner and never tells them the payment failed, so the winner has no +signal to act on — the resurrection path is, in practice, unreachable. +Net effect: a payout that can't be routed on the first invoice silently +stalls forever and requires manual operator intervention, contradicting +the §8 "non-blocking, self-healing payout" intent. + +### 9.5.2 Expected behaviour (from the issue) + +After the final `send_payment` attempt against a given invoice fails, +and **while the claim window is still open**, Mostro should: + +- **Re-request an invoice from the winner** — re-send + `Action::AddBondInvoice` — instead of going silent. +- **Not reset the claim-window deadline.** The forfeit deadline stays + anchored on `slashed_at` (§8.1 / §15.4); re-prompting must never push + it forward, or a winner whose node is briefly unroutable could be kept + on the hook indefinitely. +- **Stop prompting once a valid invoice is submitted** and routed + successfully (the bond reaches `Slashed`). + +### 9.5.3 Scope + +All changes are in `src/app/bond/payout.rs` and the scheduler cadence; +no `mostro-core` variant, no migration. + +- **Only a *terminal* failure abandons the invoice (double-payout + guard).** Abandoning the current invoice — whether by re-arming for a + fresh one or by flipping to `Failed` — clears `payout_payment_hash`, + which disables the §8.1 reconciliation branch in `pay_counterparty` + (the branch that looks the payment up in LND before re-sending). If the + in-flight `send_payment` for the current invoice could still settle, + abandoning it would let a freshly-prompted invoice be paid while the + original later succeeds — a **double payout**. So + `on_send_payment_failure` distinguishes two failure kinds: + - **Terminal** — LND reported the payment `Failed` (via the status + stream or reconciliation), or the invoice is structurally unusable. + No payment is or will be in flight, so the invoice may be abandoned. + - **Indeterminate** — status-stream timeout, stream EOF, or a + `send_payment` RPC error. The payment may still be in flight. The + invoice and its `payout_payment_hash` are **kept**; the row stays in + `PendingPayout` and the next tick's reconciliation branch polls LND + to a definitive `Succeeded` / `Failed` before anything new is paid. + `payout_attempts` saturates at `payout_max_retries` so a long LND + outage cannot grow it without bound. +- **Stale invoice on *terminal* retry exhaustion is discarded, not + terminal-`Failed`.** When `payout_attempts >= payout_max_retries` + after a **terminal** failure: + - **If `now - slashed_at < payout_claim_window_days * 86_400`** (claim + window still open): instead of transitioning to `Failed`, CAS the row + *back into the invoice-request phase* — clear `payout_invoice`, + `payout_routing_fee_sats`, and `payout_payment_hash` to `NULL`, reset + `payout_attempts = 0`, clear `last_invoice_request_at` (so the + re-prompt fires immediately), and **leave `state = PendingPayout`** + and `slashed_at` untouched. The guard is `WHERE id = ? AND state = + 'pending-payout'` so a row that raced to another state in the + meantime is left alone. + - **Else** (claim window already elapsed): transition to `Failed` as + today. Past the deadline there is no point re-prompting; `Failed` + remains the terminal "we held a valid invoice but could not route it, + and the window is closed — operator review required" state. It stays + distinct from `Forfeited` ("the winner never submitted an invoice at + all"). +- **The scheduler re-prompts on the next tick automatically.** With + `payout_invoice` now `NULL`, §8.1 step 1 fires on the next + `run_bond_payout_cycle` pass: subject to the existing + `payout_invoice_window_seconds` cadence guard, it enqueues a fresh + `Action::AddBondInvoice` (carrying the **unchanged** `slashed_at` in + `Payload::BondPayoutRequest`, so the client renders the *same* forfeit + deadline as before — §8.1), bumps `invoice_request_attempts`, and sets + `last_invoice_request_at = now`. No new code path is needed for the + re-prompt itself — clearing the invoice is what re-arms step 1. The + persist-first ordering invariant from §8.1 step 1 still holds. +- **Bounding the loop.** Re-prompting is bounded by the **forfeit + window**, not by `payout_max_retries`: the top-of-cycle forfeit check + (§8.1) keeps running, and once `now - slashed_at >= claim window` with + `payout_invoice IS NULL` the row CAS-transitions to `Forfeited` and the + node retains the full `amount_sats`. So the re-prompt/retry cycle + cannot run forever; it has exactly the same long-stop as the + never-claimed case. `invoice_request_attempts` continues to count + across re-prompts (it is bounded by the forfeit window, per §8.1, not + by the retry budget). +- **`payout_max_retries` keeps its meaning *per invoice*.** It still + caps `send_payment` attempts against a single submitted bolt11. The + change is only what happens *after* the cap is hit inside the window: + discard that bolt11 and ask for another, rather than giving up + silently. +- **Winner-facing signalling reuses Phase 3.5.** When the winner + responds to a re-prompt with a fresh bolt11, the existing + `add_bond_invoice_action` path persists it and enqueues + `Action::BondInvoiceAccepted` (Phase 3.5 §8.5) exactly as for the first + invoice — so the client sees "invoice received, payout in progress" + again and stops prompting locally until the next failure-driven + re-request, if any. On eventual success the winner still receives + `Action::BondPayoutCompleted`. No new action variant is required. + +### 9.5.4 Interaction with the §8.2 `Failed` resurrection path + +Phase 4.5 makes the in-window resurrection path the **common** path +(now Mostro-driven) rather than relying on a spontaneous client resend. +The §8.2 resurrection CAS (`Failed → PendingPayout` on a fresh +`AddBondInvoice` within the window) is **retained** as a belt-and-braces +recovery for any row that still reaches `Failed` — e.g. a row that was +already `Failed` before this phase shipped, or one that exhausted retries +exactly as the window closed. After Phase 4.5, the expected steady-state +is that an in-window payout never silently terminates; `Failed` is only +ever observed after the claim window has elapsed. + +### 9.5.5 Tests + +- **Re-request after *terminal* exhaustion, in window.** Bond in + `PendingPayout` with a submitted-but-unroutable `payout_invoice`, day 2 + of a 15-day window. Drive **terminal** `send_payment` failures up to + `payout_max_retries`. The row stays `PendingPayout`, `payout_invoice` / + `payout_routing_fee_sats` / `payout_payment_hash` / + `last_invoice_request_at` are cleared, `payout_attempts` resets to 0, + and `slashed_at` is unchanged. On the next scheduler tick a fresh + `Action::AddBondInvoice` is enqueued to the winner and + `invoice_request_attempts` increments. +- **Indeterminate exhaustion keeps the invoice (double-payout guard).** + Same setup, but the failures are **indeterminate** (timeout / EOF / + send RPC error). After `payout_max_retries` the row stays + `PendingPayout` with `payout_invoice` **and** `payout_payment_hash` + intact (so reconciliation can poll LND), is **not** re-armed and + **not** `Failed`, and `payout_attempts` saturates at + `payout_max_retries`. Further indeterminate failures keep it pinned. +- **Deadline does not move.** Across one or more re-request cycles, the + `slashed_at` field and the `slashed_at` shipped in + `Payload::BondPayoutRequest` are identical to the original slash + anchor; the forfeit deadline the client would compute is unchanged. +- **Successful re-payment closes the claim.** After a re-prompt the + winner submits a routable bolt11 → `BondInvoiceAccepted` is enqueued, + the next tick's `send_payment` succeeds, the row reaches `Slashed`, and + `BondPayoutCompleted` is enqueued. No further `AddBondInvoice` is sent. +- **Re-request loop is forfeit-bounded.** A winner whose every submitted + invoice keeps failing is re-prompted across the window; once + `now - slashed_at >= claim window` with `payout_invoice IS NULL`, the + row CAS-transitions to `Forfeited` (not an infinite loop), node retains + `amount_sats` in full. +- **Past-window exhaustion still yields `Failed`.** A late invoice that + arrives near the deadline and exhausts `payout_max_retries` *after* + `now - slashed_at >= claim window` transitions to `Failed`, not back to + the invoice-request phase — preserving the operator-review terminal. +- **`slash_node_share_pct = 1.0`.** No counterparty leg exists, so no + invoice is ever requested and this path is never reached (regression + guard). +- **`enabled = false`.** No bond payouts run; no behaviour change. + +### 9.5.6 Acceptance + +- The issue #750 failure mode is gone: a payout whose first invoice + cannot be routed no longer strands silently in `Failed`. Mostro + re-prompts the winner for a fresh invoice within the claim window. +- The forfeit deadline stays anchored on `slashed_at` across every + re-request; re-prompting cannot extend a winner's exposure. +- `Failed` becomes an out-of-window-only terminal; the in-window payout + is self-healing without operator intervention. +- Phase 3's split math and accounting are untouched — this phase only + changes the retry-exhaustion transition and reuses existing messages. + +--- + ## 10. Phase 5 — Maker bond (non-range) + dispute slash Gate: `enabled && apply_to ∈ { make, both }`. @@ -1820,12 +2049,16 @@ Tests mirror Phase 4 from the maker side; the "no slash" rows in the is. - New `Status` / `Action` / `Payload` variants in `mostro-core` must ship in that crate first and be pinned to a version in this repo's - `Cargo.toml`. As of `mostro-core` **0.11.0**, the variants for - Phases 1.5 and 2 (`Status::WaitingTakerBond`, - `Action::PayBondInvoice`, `Payload::BondResolution`) are released - and ready to pin. Phase 5's `Status::WaitingMakerBond` is still - pending in `mostro-core`. Clients must handle unknown statuses - gracefully — this is already the case. + `Cargo.toml`. As of `mostro-core` **0.11.5** (the current pin on + `main`), every variant for Phases 1.5 through 4 is released and + pinned: `Status::WaitingTakerBond`, `Action::PayBondInvoice`, + `Payload::BondResolution` (0.11.0), `Action::AddBondInvoice` + (0.11.2), `Payload::BondPayoutRequest` (0.11.3), + `Action::BondInvoiceAccepted` / `Action::BondPayoutCompleted` + (0.11.4), and `Action::BondSlashed` (0.11.5). Phase 4.5 needs no new + variant. Phase 5's `Status::WaitingMakerBond` is still pending in + `mostro-core`. Clients must handle unknown statuses gracefully — this + is already the case. - An admin/solver client that does not yet know about `BondResolution` sends `payload: null`, which the daemon interprets as "release-by-default". No silent slashes. @@ -1846,7 +2079,8 @@ these requires a compatibility statement: the buyer-invoice `Action::AddInvoice` so the daemon can route on action type alone. - `Payload::BondPayoutRequest` variant in mostro-core (Phase 3). - **Targets `mostro-core` 0.11.3** (not yet released). Carries + **Released in `mostro-core` 0.11.3** (pinned via the 0.11.5 bump on + `main`). Carries `{ order: SmallOrder, slashed_at: i64 }` on `Action::AddBondInvoice` so the client can compute the forfeit deadline from the slash anchor instead of from message receipt time. Without this anchor a @@ -1880,6 +2114,11 @@ these requires a compatibility statement: risk. `MessageKind::verify` accepts it like the other Mostro → user notifications (id required; `BondResolution` / `BondPayoutRequest` payloads rejected). No new `CantDoReason` is needed. +- Phase 4.5 (§9.5). **No upstream dependency — daemon-side only.** + Reuses `Action::AddBondInvoice` (Phase 3) and + `Action::BondInvoiceAccepted` (Phase 3.5); it only changes the + `send_payment`-exhaustion transition in `src/app/bond/payout.rs`. No + new variant, no `mostro-core` bump, no migration. - `Status::WaitingMakerBond` (Phase 5). Not yet shipped upstream; needs a follow-up `mostro-core` minor release before Phase 5 can land here. diff --git a/src/app/bond/payout.rs b/src/app/bond/payout.rs index 80c47db1..7ec72ef4 100644 --- a/src/app/bond/payout.rs +++ b/src/app/bond/payout.rs @@ -137,11 +137,25 @@ pub async fn run_bond_payout_cycle(pool: &Pool, ln_client: &mut LndConne /// │ │ /// └──── send_payment success ─► Slashed │ /// │ -/// send_payment failure ─► retry (or Failed) ◄──┘ +/// retries left ─► retry next tick ◄── send_payment failure ◄─────┤ +/// │ +/// keep invoice (reconcile) ◄── retries exhausted, indeterminate failure ◄───┤ +/// │ +/// re-arm (clear invoice, re-prompt) ◄── exhausted, terminal, in claim window ◄──┤ +/// │ +/// Failed ◄── exhausted, terminal, past claim window ◄─┘ /// ``` /// /// Each call advances by at most one of these arms; the scheduler /// reruns the row on the next tick until a terminal state is reached. +/// The re-arm arm (Phase 4.5, [issue #750]) discards an unroutable +/// invoice and loops the row back to the invoice-request sub-phase so +/// the winner is re-prompted; it is bounded by the same forfeit window +/// as the never-claimed case. An *indeterminate* failure (timeout / EOF +/// / send RPC error) never abandons the invoice — see +/// [`PaymentFailureKind`]. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 async fn process_one_bond( pool: &Pool, ln_client: &mut LndConnector, @@ -190,7 +204,17 @@ async fn process_one_bond( match bond.payout_invoice.as_deref() { None => request_payout_invoice(pool, bond, invoice_window_seconds).await, - Some(invoice) => pay_counterparty(pool, ln_client, bond, invoice, max_retries).await, + Some(invoice) => { + pay_counterparty( + pool, + ln_client, + bond, + invoice, + max_retries, + claim_window_seconds, + ) + .await + } } } @@ -476,6 +500,7 @@ async fn pay_counterparty( bond: &Bond, invoice: &str, max_retries: i64, + claim_window_seconds: i64, ) -> Result<(), MostroError> { let counterparty_share = counterparty_share_sats(bond)?; @@ -486,10 +511,15 @@ async fn pay_counterparty( let decoded = match decode_invoice(invoice) { Ok(d) => d, Err(e) => { + // Structurally unusable invoice — no payment is or will be in + // flight for it, so this is a terminal failure (safe to + // abandon and re-prompt for a usable one). return on_send_payment_failure( pool, bond, max_retries, + claim_window_seconds, + PaymentFailureKind::Terminal, &format!("payout invoice decode failed: {e}"), ) .await; @@ -518,10 +548,14 @@ async fn pay_counterparty( return slash_after_success(pool, bond, counterparty_share).await; } Ok(Some(PaymentStatus::Failed)) => { + // LND confirms the prior payment for this invoice + // failed — terminal, safe to abandon the invoice. return on_send_payment_failure( pool, bond, max_retries, + claim_window_seconds, + PaymentFailureKind::Terminal, "tracked payment reported Failed on reconciliation", ) .await; @@ -595,24 +629,41 @@ async fn pay_counterparty( .send_payment(invoice, counterparty_share, tx) .await; if let Err(e) = send_outcome { - return on_send_payment_failure(pool, bond, max_retries, &format!("{e}")).await; + // The RPC call itself errored. We cannot be sure the payment did + // not partially enter LND, so treat it as indeterminate: keep + // the invoice + hash for reconciliation rather than risk a + // double payout by re-prompting. + return on_send_payment_failure( + pool, + bond, + max_retries, + claim_window_seconds, + PaymentFailureKind::Indeterminate, + &format!("{e}"), + ) + .await; } // Collect the first terminal status from the stream. Mirrors // dev_fee::send_dev_fee_payment, but each recv is bounded by // `PAYMENT_STATUS_RECV_TIMEOUT` so a wedged LND stream (no terminal // update, no EOF, no InFlight churn) does not pin the scheduler - // task forever. A timeout and a clean EOF are both routed through - // `on_send_payment_failure` so the retry budget governs the - // recovery path uniformly. + // task forever. We track *why* the stream ended: only an explicit + // `PaymentStatus::Failed` is terminal. A timeout or clean EOF leaves + // the payment outcome unknown (it may still be in flight), so it is + // routed as `Indeterminate` — `on_send_payment_failure` then keeps + // the invoice + hash for reconciliation instead of re-prompting. let mut succeeded = false; - let mut failure: Option = None; + let mut failure: Option<(PaymentFailureKind, String)> = None; loop { match timeout(PAYMENT_STATUS_RECV_TIMEOUT, rx.recv()).await { Err(_) => { - failure = Some(format!( - "payment status stream timed out after {}s without a terminal update", - PAYMENT_STATUS_RECV_TIMEOUT.as_secs() + failure = Some(( + PaymentFailureKind::Indeterminate, + format!( + "payment status stream timed out after {}s without a terminal update", + PAYMENT_STATUS_RECV_TIMEOUT.as_secs() + ), )); break; } @@ -625,9 +676,9 @@ async fn pay_counterparty( break; } PaymentStatus::Failed => { - failure = Some(format!( - "payment failed: reason {}", - msg.payment.failure_reason + failure = Some(( + PaymentFailureKind::Terminal, + format!("payment failed: reason {}", msg.payment.failure_reason), )); break; } @@ -642,8 +693,13 @@ async fn pay_counterparty( return slash_after_success(pool, bond, counterparty_share).await; } - let msg = failure.unwrap_or_else(|| "payment stream ended without terminal status".to_string()); - on_send_payment_failure(pool, bond, max_retries, &msg).await + // EOF with no terminal status (the `Ok(None)` break above) is also + // indeterminate: the stream closed without telling us the outcome. + let (kind, msg) = failure.unwrap_or(( + PaymentFailureKind::Indeterminate, + "payment stream ended without terminal status".to_string(), + )); + on_send_payment_failure(pool, bond, max_retries, claim_window_seconds, kind, &msg).await } /// Flip a `PendingPayout` row to `Slashed` after a confirmed payment. @@ -717,16 +773,82 @@ async fn slash_after_success( Err(MostroInternalErr(ServiceError::DbAccessError(cause))) } -/// Bump `payout_attempts`; on `payout_max_retries` reached, transition -/// the bond to `Failed`. This counter only increments on real -/// `send_payment` failures, not on invoice-request messages. +/// Whether a `send_payment` attempt failed in a way LND has confirmed +/// is terminal (the payment will not settle) or in a way that leaves the +/// outcome unknown (the payment may still be in flight). +/// +/// This distinction is load-bearing for the Phase 4.5 re-prompt path +/// ([issue #750] review). Abandoning the current invoice — either by +/// re-arming for a fresh one or by giving up to `Failed` — clears +/// `payout_payment_hash`, which disables [`pay_counterparty`]'s +/// reconciliation branch. Doing that while a payment is still in flight +/// would let a freshly-prompted invoice be paid while the original later +/// settles: a **double payout**. So an invoice may only be abandoned on +/// a `Terminal` failure. An `Indeterminate` one keeps the invoice and +/// its hash so reconciliation can poll LND to a definitive answer on a +/// later tick. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum PaymentFailureKind { + /// LND reported the payment as `Failed` (via the status stream or + /// reconciliation), or the invoice is structurally unusable — no + /// payment is or will be in flight for it. + Terminal, + /// Outcome unknown: status-stream timeout, stream EOF, or a + /// `send_payment` RPC error. The payment may still be in flight. + Indeterminate, +} + +/// Bump `payout_attempts` after a failed `send_payment`. This counter +/// only increments on real `send_payment` failures, not on +/// invoice-request messages. +/// +/// What happens once `payout_max_retries` is reached against a single +/// invoice depends on **both** the failure kind and the forfeit window +/// (Phase 4.5, [issue #750]): +/// +/// - **Indeterminate failure** (timeout / stream EOF / send RPC error): +/// the payment may still be in flight, so the invoice is **never +/// abandoned** here. The row keeps its `payout_invoice` + +/// `payout_payment_hash` and `pay_counterparty`'s reconciliation +/// branch drives it to a definitive Succeeded / Failed on a later +/// tick. This is the guard against the double-payout the review +/// flagged. +/// - **Terminal failure, inside the claim window** +/// (`now - slashed_at < claim_window`): the unroutable invoice is +/// discarded and the row is re-armed back into the invoice-request +/// sub-phase (`payout_invoice`, `payout_routing_fee_sats`, +/// `payout_payment_hash`, and `last_invoice_request_at` cleared; +/// `payout_attempts` reset to 0; `state` stays `PendingPayout`). The +/// next scheduler tick sees `payout_invoice IS NULL` and re-prompts +/// the winner via `request_payout_invoice`. The `slashed_at` anchor is +/// **never touched**, so re-prompting cannot extend the winner's +/// exposure — the forfeit deadline stays fixed and the +/// re-prompt/retry cycle is bounded by `payout_claim_window_days` +/// (after which `process_one_bond` forfeits the row). This makes the +/// previously-unreachable §8.2 recovery Mostro-driven instead of +/// relying on the winner spontaneously resubmitting. +/// - **Terminal failure, outside the claim window**: there is no point +/// re-prompting past the deadline, so the row transitions to `Failed` +/// — the terminal "we held a valid invoice but could not route it and +/// the window is closed" state, distinct from `Forfeited` (the winner +/// never submitted an invoice at all). `Failed` requires operator +/// attention. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 async fn on_send_payment_failure( pool: &Pool, bond: &Bond, max_retries: i64, + claim_window_seconds: i64, + kind: PaymentFailureKind, cause: &str, ) -> Result<(), MostroError> { - let new_attempts = bond.payout_attempts + 1; + // Saturate at `max_retries`: an indeterminate failure never abandons + // the invoice (see below), so without a cap a long LND outage could + // grow the counter without bound. + let new_attempts = std::cmp::min(bond.payout_attempts + 1, max_retries); sqlx::query("UPDATE bonds SET payout_attempts = ? WHERE id = ? AND state = ?") .bind(new_attempts) .bind(bond.id) @@ -735,29 +857,111 @@ async fn on_send_payment_failure( .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - if new_attempts >= max_retries { - sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") - .bind(BondState::Failed.to_string()) - .bind(bond.id) - .bind(BondState::PendingPayout.to_string()) - .execute(pool) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - error!( + if new_attempts < max_retries { + warn!( bond_id = %bond.id, order_id = %bond.order_id, attempts = new_attempts, - "bond payout: send_payment exhausted retries — transitioning to Failed; node share retained, counterparty share stranded (operator review required). last error: {cause}" + max_retries, + "bond payout: send_payment failure ({cause}); will retry on next tick" ); - } else { + return Ok(()); + } + + // Budget exhausted for the current invoice. An *indeterminate* + // failure means the payment may still be in flight, so we must NOT + // abandon the invoice: clearing `payout_invoice` / + // `payout_payment_hash` would let a freshly-prompted invoice be paid + // while the original later settles (double payout). Keep the invoice + // + hash so `pay_counterparty`'s reconciliation branch resolves the + // payment to a definitive Succeeded / Failed on a later tick; only a + // Terminal failure (handled below) ever abandons the invoice. + if kind == PaymentFailureKind::Indeterminate { warn!( bond_id = %bond.id, order_id = %bond.order_id, attempts = new_attempts, - max_retries, - "bond payout: send_payment failure ({cause}); will retry on next tick" + "bond payout: retry budget exhausted on an indeterminate failure ({cause}); keeping the current invoice for LND reconciliation — not re-prompting (payment may still be in flight)" ); + return Ok(()); + } + + // Terminal failure: no payment is in flight for this invoice, so it + // is safe to abandon it. Decide between re-prompting the winner + // (in-window) and giving up (out-of-window) based on the forfeit + // deadline anchored on `slashed_at`. A missing `slashed_at` is an + // invariant violation (Phase 2's slash CAS writes it atomically with + // the transition to `PendingPayout`); treat it conservatively as + // out-of-window so a corrupted row terminates in `Failed` for + // operator review rather than looping forever. + let now = Utc::now().timestamp(); + let within_window = bond + .slashed_at + .is_some_and(|slashed_at| now - slashed_at < claim_window_seconds); + + if within_window { + // Phase 4.5: discard the unroutable invoice and re-arm the + // invoice-request sub-phase. `request_payout_invoice` gates on + // `payout_invoice IS NULL`, so clearing it is what re-prompts + // the winner on the next tick. `last_invoice_request_at` is + // cleared so the re-prompt fires immediately (the winner has + // already waited through a full retry budget). `payout_attempts` + // resets to 0 so the next invoice gets a fresh budget; + // `payout_routing_fee_sats` / `payout_payment_hash` are cleared + // because they describe the discarded attempt. `slashed_at` and + // `invoice_request_attempts` are intentionally left untouched — + // the deadline must not move, and the nudge counter is bounded + // by the forfeit window (not the retry budget) so it keeps + // accumulating across re-prompts for operator visibility. + let result = sqlx::query( + "UPDATE bonds \ + SET payout_invoice = NULL, \ + payout_routing_fee_sats = NULL, \ + payout_payment_hash = NULL, \ + payout_attempts = 0, \ + last_invoice_request_at = NULL \ + WHERE id = ? AND state = ?", + ) + .bind(bond.id) + .bind(BondState::PendingPayout.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 1 { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + attempts = new_attempts, + "bond payout: send_payment exhausted retries for this invoice ({cause}); re-requesting a fresh invoice from the winner (still inside claim window, deadline unchanged)" + ); + } else { + // The row moved off `PendingPayout` between the attempts + // bump and this re-arm CAS (e.g. a concurrent forfeit at + // the window edge). Nothing to do — the winning transition + // already decided this row's fate. + info!( + bond_id = %bond.id, + order_id = %bond.order_id, + "bond payout: re-arm CAS missed (concurrent transition); leaving row as-is" + ); + } + return Ok(()); } + + sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") + .bind(BondState::Failed.to_string()) + .bind(bond.id) + .bind(BondState::PendingPayout.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + error!( + bond_id = %bond.id, + order_id = %bond.order_id, + attempts = new_attempts, + "bond payout: send_payment exhausted retries past the claim window — transitioning to Failed; node share retained, counterparty share stranded (operator review required). last error: {cause}" + ); Ok(()) } @@ -1599,28 +1803,39 @@ mod tests { } #[tokio::test] - async fn send_payment_failure_increments_attempts_and_flips_to_failed() { - // After `max_retries` consecutive `send_payment` failures the - // row must transition `PendingPayout -> Failed`. Exercises - // `on_send_payment_failure` with a tight retry budget. + async fn send_payment_failure_past_window_flips_to_failed() { + // After `max_retries` consecutive `send_payment` failures, a bond + // whose claim window has already elapsed must transition + // `PendingPayout -> Failed` (Phase 4.5: re-prompting is pointless + // past the deadline). Exercises `on_send_payment_failure` with a + // tight retry budget and an out-of-window `slashed_at`. let pool = setup_pool().await; let order_id = Uuid::new_v4(); insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + // slashed_at older than the claim window → out of window. + let slashed_at = Utc::now().timestamp() - (CLAIM_WINDOW_SECONDS + 86_400); let bond = pending_payout_bond( order_id, taker_pk(), 10_000, 5_000, - Utc::now().timestamp(), + slashed_at, Some("lnbc1pSOMETHING"), None, ); let bond = create_bond(&pool, bond).await.unwrap(); // First failure: attempts 0 -> 1, still PendingPayout. - on_send_payment_failure(&pool, &bond, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_1: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1631,9 +1846,16 @@ mod tests { // Second + third failures use the *fresh* row each time so the // counter math is exercised end-to-end. Third must flip Failed. - on_send_payment_failure(&pool, &bond_after_1, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond_after_1, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_2: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1641,9 +1863,16 @@ mod tests { .unwrap(); assert_eq!(bond_after_2.payout_attempts, 2); - on_send_payment_failure(&pool, &bond_after_2, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond_after_2, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_3: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1653,6 +1882,181 @@ mod tests { assert_eq!(bond_after_3.state, BondState::Failed.to_string()); } + #[tokio::test] + async fn send_payment_failure_within_window_reprompts_winner() { + // Phase 4.5 / issue #750: after `max_retries` send_payment + // failures against a submitted invoice, a bond still inside its + // claim window must NOT terminate in `Failed`. Instead it + // re-arms the invoice-request sub-phase: `payout_invoice` and + // the per-attempt columns are cleared, `payout_attempts` resets + // to 0, the row stays `PendingPayout`, and `slashed_at` (the + // forfeit anchor) is left untouched so the deadline never moves. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + let slashed_at = Utc::now().timestamp(); + let mut bond = pending_payout_bond( + order_id, + taker_pk(), + 10_000, + 5_000, + slashed_at, + Some("lnbc1pSTALE"), + Some(slashed_at), // last_invoice_request_at set + ); + // Simulate the per-attempt state accumulated during the failed + // send: a routing-fee cap and a payment hash from the stale + // invoice, plus one prior nudge to the winner. + bond.payout_routing_fee_sats = Some(50); + bond.payout_payment_hash = Some("deadbeef".to_string()); + bond.invoice_request_attempts = 1; + let bond = create_bond(&pool, bond).await.unwrap(); + + // First two failures: budget not exhausted, row stays put with + // its invoice and counters intact. + on_send_payment_failure( + &pool, + &bond, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_1: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after_1.payout_attempts, 1); + assert_eq!(after_1.state, BondState::PendingPayout.to_string()); + assert_eq!(after_1.payout_invoice.as_deref(), Some("lnbc1pSTALE")); + + on_send_payment_failure( + &pool, + &after_1, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_2: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after_2.payout_attempts, 2); + + // Third failure hits `max_retries` (3) inside the window → re-arm. + on_send_payment_failure( + &pool, + &after_2, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_3: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + + // Re-armed back into the invoice-request sub-phase, NOT Failed. + assert_eq!(after_3.state, BondState::PendingPayout.to_string()); + assert!(after_3.payout_invoice.is_none()); + assert!(after_3.payout_routing_fee_sats.is_none()); + assert!(after_3.payout_payment_hash.is_none()); + assert!(after_3.last_invoice_request_at.is_none()); + assert_eq!(after_3.payout_attempts, 0); + // Forfeit anchor untouched: the deadline must not move. + assert_eq!(after_3.slashed_at, Some(slashed_at)); + // Nudge counter is bounded by the forfeit window, not the retry + // budget, so it is preserved across the re-prompt. + assert_eq!(after_3.invoice_request_attempts, 1); + } + + #[tokio::test] + async fn send_payment_indeterminate_failure_keeps_invoice() { + // Double-payout guard (issue #750 review): when the retry budget + // is exhausted by *indeterminate* failures (status-stream + // timeout / EOF / send RPC error), the payment for the current + // invoice may still be in flight. The row must keep its invoice + // AND `payout_payment_hash` so `pay_counterparty`'s + // reconciliation branch can poll LND before any new invoice is + // paid — it must NOT re-arm (clear) or flip to `Failed`, even + // well inside the claim window. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + let slashed_at = Utc::now().timestamp(); + let mut bond = pending_payout_bond( + order_id, + taker_pk(), + 10_000, + 5_000, + slashed_at, + Some("lnbc1pINFLIGHT"), + Some(slashed_at), + ); + bond.payout_routing_fee_sats = Some(50); + bond.payout_payment_hash = Some("cafebabe".to_string()); + let bond = create_bond(&pool, bond).await.unwrap(); + + // Exhaust the budget with indeterminate failures. + let mut current = bond; + for _ in 0..3 { + on_send_payment_failure( + &pool, + ¤t, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Indeterminate, + "stream timed out", + ) + .await + .unwrap(); + current = sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ?") + .bind(current.id) + .fetch_one(&pool) + .await + .unwrap(); + } + + // Still PendingPayout, invoice + hash intact for reconciliation. + assert_eq!(current.state, BondState::PendingPayout.to_string()); + assert_eq!(current.payout_invoice.as_deref(), Some("lnbc1pINFLIGHT")); + assert_eq!(current.payout_payment_hash.as_deref(), Some("cafebabe")); + // Counter saturates at max_retries rather than growing unbounded. + assert_eq!(current.payout_attempts, 3); + + // A further indeterminate failure keeps it pinned, never Failed. + on_send_payment_failure( + &pool, + ¤t, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Indeterminate, + "stream eof", + ) + .await + .unwrap(); + let after: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(current.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after.state, BondState::PendingPayout.to_string()); + assert_eq!(after.payout_attempts, 3); + assert_eq!(after.payout_invoice.as_deref(), Some("lnbc1pINFLIGHT")); + assert_eq!(after.payout_payment_hash.as_deref(), Some("cafebabe")); + } + #[tokio::test] async fn finalize_node_only_transitions_to_slashed() { // `slash_node_share_pct = 1.0` style row: counterparty share is @@ -1981,11 +2385,22 @@ mod tests { // Drive back to Failed via three consecutive send_payment // failures with retry budget = 3. Each call re-reads the row // so the counter math is exercised against fresh state. + // `claim_window_seconds = 0` forces the out-of-window branch so + // exhaustion terminates in `Failed` (Phase 4.5: an in-window + // exhaustion would re-arm instead) — here we want a `Failed` row + // to feed the second resurrection. let mut current = bond_after_b; for _ in 0..3 { - on_send_payment_failure(&pool, ¤t, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + ¤t, + 3, + 0, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); current = sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) From 828114baff4541210e6445cc382550ea69a7f259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Tue, 9 Jun 2026 12:45:41 +0200 Subject: [PATCH 04/23] =?UTF-8?q?feat(bond):=20Phase=205=20=E2=80=94=20mak?= =?UTF-8?q?er=20bond=20(non-range)=20+=20dispute=20slash=20(#767)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 +- Cargo.toml | 2 +- docs/ANTI_ABUSE_BOND.md | 8 + .../20260530120000_cashu_escrow_fields.sql | 11 + src/app/bond/flow.rs | 648 ++++++++++++++++-- src/app/bond/mod.rs | 6 +- src/app/bond/payout.rs | 12 + src/app/bond/slash.rs | 384 ++++++++++- src/app/cancel.rs | 10 +- src/app/dev_fee.rs | 6 + src/app/take_buy.rs | 15 +- src/app/take_sell.rs | 15 +- src/db.rs | 38 +- src/nip33.rs | 20 + src/scheduler.rs | 32 + src/util.rs | 222 +++++- 16 files changed, 1335 insertions(+), 98 deletions(-) create mode 100644 migrations/20260530120000_cashu_escrow_fields.sql diff --git a/Cargo.lock b/Cargo.lock index 28ba8afe..782a2976 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1769,9 +1769,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.11.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964e2810ab700532c3e6677615123372bf9a0ef3fbba4e36b9d65e98f3734ef0" +checksum = "f92c273ca52a38a27cdd74f3635055e092c0253d7fade399d64a1d5be63f8563" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f18ddbc2..3991d5c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = { version = "0.11.5", features = ["sqlx"] } +mostro-core = { version = "0.12.1", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } async-trait = "0.1.83" diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index b16cbcca..46f4e68c 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -1830,6 +1830,14 @@ buyer/seller resolution operates over: - Order completed (release path) → maker bond released. - Order cancelled before take, or expires `Pending` → maker bond released. +- **Take attempt times out and the order is republished** (the taker is + the responsible party — `(WaitingBuyerInvoice, sell)` / + `(WaitingPayment, buy)`) → the maker bond **stays `Locked`**. The order + returns to the book with the maker still committed; only the abandoning + taker bond is resolved (slashed under §9.2, else released). The maker + bond is released only when the order itself terminates. Handled in + `slash_or_release_on_timeout` via the republish-aware release routing + (`release_taker_bonds_for_order_or_warn`). - Solver dispute resolution: `BondResolution { slash_seller, slash_buyer }` resolves to the maker bond when the maker is on the named side per §3.1 (sell-order → `slash_seller` targets maker; buy-order → diff --git a/migrations/20260530120000_cashu_escrow_fields.sql b/migrations/20260530120000_cashu_escrow_fields.sql new file mode 100644 index 00000000..0ff148b9 --- /dev/null +++ b/migrations/20260530120000_cashu_escrow_fields.sql @@ -0,0 +1,11 @@ +-- mostro-core 0.12.1 adds three Cashu escrow fields to the `Order` model. +-- These columns back the Cashu 2-of-3 multisig escrow mode (see the escrow +-- architecture spec). They are `NULL` for Lightning orders. +-- +-- * cashu_mint_url URL of the Cashu mint hosting the escrow. +-- * cashu_escrow_token Serialized Cashu 2-of-3 multisig token held as escrow. +-- * cashu_escrow_locked_at Unix timestamp (seconds) when the escrow token was +-- validated and locked in. +ALTER TABLE orders ADD COLUMN cashu_mint_url text; +ALTER TABLE orders ADD COLUMN cashu_escrow_token text; +ALTER TABLE orders ADD COLUMN cashu_escrow_locked_at integer; diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index 5a0ce64d..afc23432 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -75,6 +75,36 @@ pub fn taker_bond_required() -> bool { .is_some_and(|cfg| cfg.apply_to.applies_to_taker()) } +/// True when the configuration requires the **maker** to post a bond. +/// +/// Phase 5 gate, symmetric to [`taker_bond_required`]. `publish_order` +/// asks this question before publishing a new order to Nostr: when it is +/// true (and the order is non-range — Phase 6 handles range makers), the +/// order is parked at [`Status::WaitingMakerBond`] and no NIP-33 event is +/// emitted until the maker locks the bond. +pub fn maker_bond_required() -> bool { + Settings::get_bond() + .filter(|cfg| cfg.enabled) + .is_some_and(|cfg| cfg.apply_to.applies_to_maker()) +} + +/// True when a `Locked` **taker** bond already exists among `bonds` — the +/// signal that the order's trade is committed and no further take may +/// begin (it must be rejected with `PendingOrderExists`). +/// +/// The role scoping is load-bearing for Phase 5. Under `apply_to = both` +/// the maker's own bond is `Locked` on *every* published order — that is +/// the steady state, not a committed trade. Counting it here would reject +/// every taker. Only a `Locked` *taker* bond marks the +/// first-to-lock-wins race as decided (§6.5), so the take handlers +/// (`take_buy_action` / `take_sell_action`) gate on this predicate +/// instead of "any Locked bond". +pub fn trade_committed_by_locked_taker_bond(bonds: &[Bond]) -> bool { + let locked = BondState::Locked.to_string(); + let taker = BondRole::Taker.to_string(); + bonds.iter().any(|b| b.state == locked && b.role == taker) +} + /// Per-take context that the take handler computed locally and now /// stashes on the bond row instead of mutating the order. /// @@ -280,6 +310,119 @@ pub async fn request_taker_bond( Ok(bond) } +/// Create a hold invoice for the **maker's** bond, persist a `Bond` row +/// in `Requested`, ship the bolt11 to the maker, and arm the LND +/// subscriber that flips the row to `Locked` once the maker pays. +/// +/// Phase 5 counterpart of [`request_taker_bond`]. Unlike the taker side +/// there is exactly one maker bond per order (no concurrent-bonds race), +/// and the order has already been persisted at +/// [`Status::WaitingMakerBond`] by `publish_order` with **no** NIP-33 +/// event emitted — the order stays invisible in the book until the bond +/// locks. On `Accepted`, [`on_bond_invoice_accepted`] resumes the +/// deferred publication (see `crate::util::resume_publish_after_maker_bond`). +/// +/// `notional_sats` is the sats notional the bond is sized against: the +/// fixed order amount for a fixed-price order, or the price-converted +/// fiat amount for a market-priced single order. Range orders never +/// reach this function in Phase 5 (deferred to Phase 6). +/// +/// On any failure the bond row may exist in `Requested` with no LND +/// counterpart; the order will be reaped (and the bond released) by the +/// `WaitingMakerBond` expiry path, mirroring the taker "always release" +/// contract. +pub async fn request_maker_bond( + pool: &Pool, + order: &Order, + maker_pubkey: PublicKey, + notional_sats: i64, + request_id: Option, + trade_index: Option, +) -> Result { + let cfg = Settings::get_bond().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "anti_abuse_bond block is missing while maker bond was deemed required".into(), + )) + })?; + + let amount = compute_bond_amount(notional_sats, cfg); + let memo = format!("mostro bond order_id={}", order.id); + + let mut ln_client = LndConnector::new().await?; + let (invoice_resp, preimage, hash) = ln_client + .create_hold_invoice(&memo, amount) + .await + .map_err(|e| MostroInternalErr(ServiceError::HoldInvoiceError(e.to_string())))?; + + let mut bond = Bond::new_requested(order.id, maker_pubkey.to_string(), BondRole::Maker, amount); + bond.hash = Some(bytes_to_string(&hash)); + bond.preimage = Some(bytes_to_string(&preimage)); + bond.payment_request = Some(invoice_resp.payment_request.clone()); + // No `taker_*` context on a maker bond: those columns describe the + // deferred take snapshot of a concurrent taker bond and stay NULL here. + + let bond = create_bond(pool, bond).await?; + + info!( + "Maker bond requested: bond_id={} order_id={} amount_sats={}", + bond.id, order.id, bond.amount_sats + ); + + // The bond bolt11 ships as a dedicated `Action::PayBondInvoice`, same + // as the taker side. The order is not on the wire yet (no NIP-33 + // event), so the `SmallOrder` carries `Status::Pending` purely as a + // neutral placeholder for the client. + let order_kind = order.get_order_kind().map_err(MostroInternalErr)?; + let bond_small = SmallOrder::new( + Some(order.id), + Some(order_kind), + Some(Status::Pending), + amount, + order.fiat_code.clone(), + order.min_amount, + order.max_amount, + order.fiat_amount, + order.payment_method.clone(), + order.premium, + None, + None, + None, + None, + None, + ); + + // Arm the subscriber BEFORE shipping the bolt11 (same ordering + // rationale as the taker side: a fast payer must not race ahead of + // the listener). On subscribe failure, release the row so we don't + // strand a `Requested` bond with no listener. + if let Err(e) = bond_invoice_subscribe(hash, request_id).await { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + "request_maker_bond: subscribe failed ({}); rolling back bond row", + e + ); + let _ = release_bond(pool, &bond).await; + return Err(e); + } + + enqueue_order_msg( + request_id, + Some(order.id), + Action::PayBondInvoice, + Some(Payload::PaymentRequest( + Some(bond_small), + invoice_resp.payment_request, + None, + )), + maker_pubkey, + trade_index, + ) + .await; + + Ok(bond) +} + /// Outcome of a `cancel_hold_invoice` attempt against LND, classified /// from the structured gRPC error so the caller can decide whether the /// HTLC is verifiably no longer encumbered. @@ -449,9 +592,30 @@ pub async fn release_bond(pool: &Pool, bond: &Bond) -> Result<(), Mostro pub async fn release_bonds_for_order( pool: &Pool, order_id: Uuid, +) -> Result<(), MostroError> { + release_active_bonds(pool, order_id, false).await +} + +/// Release every active bond on `order_id`, optionally **retaining** the +/// maker's bond. +/// +/// `retain_makers = true` is the waiting-timeout **republish** path: the +/// order returns to the book, the maker is still committed to it, so its +/// `Locked` bond must stay put and be resolved only when the order itself +/// terminates (completed, cancelled, or expired `Pending`). Only the +/// abandoning taker side is released. Every other path releases all bonds +/// (`retain_makers = false`). +async fn release_active_bonds( + pool: &Pool, + order_id: Uuid, + retain_makers: bool, ) -> Result<(), MostroError> { let bonds = find_active_bonds_for_order(pool, order_id).await?; + let maker = BondRole::Maker.to_string(); for bond in bonds.iter() { + if retain_makers && bond.role == maker { + continue; + } if let Err(e) = release_bond(pool, bond).await { warn!("Failed to release bond {}: {}", bond.id, e); } @@ -476,6 +640,21 @@ pub async fn release_bonds_for_order_or_warn( } } +/// Like [`release_bonds_for_order_or_warn`] but **retains the maker's +/// bond** — the waiting-timeout republish path (see [`release_active_bonds`]). +/// The maker's `Locked` bond stays put because the order returns to the +/// book with the maker still committed; only the abandoning taker side is +/// released. +pub async fn release_taker_bonds_for_order_or_warn( + pool: &Pool, + order_id: Uuid, + context: &'static str, +) { + if let Err(e) = release_active_bonds(pool, order_id, true).await { + warn!("{context}: bond release failed for {}: {}", order_id, e); + } +} + /// Spawn the LND subscriber for a bond hold invoice. The subscriber /// transitions the bond row through `Locked` / `Released` based on the /// invoice state and, on `Locked`, resumes the original take flow. @@ -600,16 +779,33 @@ async fn on_bond_invoice_accepted( } }; + // Phase 5: a maker bond is a singleton (one per order, posted at + // order-creation time), so it never participates in the taker + // first-to-lock-wins race below. Route it to its own lock + resume + // path, which finishes the deferred order publication. + if bond.role == BondRole::Maker.to_string() { + return on_maker_bond_accepted(&bond, hash, pool, request_id).await; + } + // Atomic Requested → Locked with concurrent-bonds guard. Exactly // one bond can win per order — if two `Accepted` events arrive in // the same window, the loser's UPDATE returns `rows_affected = 0`. + // + // Phase 5: the guard counts only OTHER `Locked` *taker* bonds. The + // first-to-lock-wins race is purely a taker concern; under + // `apply_to = both` the maker's own bond is already `Locked` on every + // published order (that is the steady state, not a competitor). Without + // the `role = 'taker'` filter the `NOT EXISTS` subquery would see the + // maker bond, this UPDATE would affect zero rows, and the first taker + // to pay would be wrongly treated as a race loser — rejecting every + // taker on a maker-bonded order. let now = Utc::now().timestamp(); let result = sqlx::query( "UPDATE bonds SET state = ?, locked_at = ? \ WHERE id = ? AND state = ? \ AND NOT EXISTS ( \ SELECT 1 FROM bonds b2 \ - WHERE b2.order_id = ? AND b2.state = ? AND b2.id != ? \ + WHERE b2.order_id = ? AND b2.state = ? AND b2.role = ? AND b2.id != ? \ )", ) .bind(BondState::Locked.to_string()) @@ -618,6 +814,7 @@ async fn on_bond_invoice_accepted( .bind(BondState::Requested.to_string()) .bind(bond.order_id) .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) .bind(bond.id) .execute(pool) .await @@ -736,6 +933,90 @@ async fn on_bond_invoice_accepted( resume_take_after_bond(pool, order, &my_keys, request_id).await } +/// Subscriber callback path for a **maker** bond reaching `Accepted`. +/// +/// The maker bond is a singleton, so there is no first-to-lock-wins race +/// and no loser to cancel. We atomically flip `Requested → Locked`, then +/// — if the order is still parked at `WaitingMakerBond` — resume the +/// deferred order publication that `publish_order` skipped. +/// +/// Idempotent across redeliveries and the restart resubscriber: a +/// duplicate firing for an already-`Locked` bond re-reads `Locked` and +/// falls through to the resume, which itself no-ops once the order has +/// moved on to `Pending` (already published). +async fn on_maker_bond_accepted( + bond: &Bond, + hash: &str, + pool: &Pool, + request_id: Option, +) -> Result<(), MostroError> { + let now = Utc::now().timestamp(); + let result = + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(now) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Re-read so a concurrent release (e.g. the order expired and its + // bond was cancelled) is visible before we try to publish. + let current = match find_bond_by_hash(pool, hash).await? { + Some(b) => b, + None => return Ok(()), + }; + let current_state = match BondState::from_str(¤t.state) { + Ok(s) => s, + Err(e) => { + warn!( + "Maker bond {} has unparseable state {:?}: {} — skipping publish", + current.id, current.state, e + ); + return Ok(()); + } + }; + if current_state != BondState::Locked { + info!( + "Maker bond {} no longer Locked (state={}) — skipping publish", + current.id, current.state + ); + return Ok(()); + } + if result.rows_affected() == 1 { + info!( + "Maker bond {} locked for order {}", + current.id, current.order_id + ); + } + + let order = Order::by_id(pool, current.order_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? + .ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Maker bond {} references missing order {}", + current.id, current.order_id + ))) + })?; + + // Only resume the deferred publication while the order is still + // parked at `WaitingMakerBond`. A previous firing (or the restart + // resubscriber) may already have published it (status `Pending`), + // in which case we must not re-publish or re-ack the maker. + if order.status != Status::WaitingMakerBond.to_string() { + info!( + "Maker bond {} accepted but order {} is in status {} — skipping publish", + current.id, order.id, order.status + ); + return Ok(()); + } + + let my_keys = get_keys()?; + crate::util::resume_publish_after_maker_bond(pool, &my_keys, order, request_id).await +} + /// Message the taker of a losing concurrent bond that their take was /// cancelled because another taker locked their bond first. async fn notify_loser(bond: &Bond) { @@ -855,15 +1136,15 @@ async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), Ok(()) } -/// If `order_id` is currently in `Status::WaitingTakerBond` and has no -/// remaining active bond rows, transition it back to `Status::Pending` -/// and republish the NIP-33 event. No-op otherwise. +/// Atomically transition `order_id` from `WaitingTakerBond` back to +/// `Pending` **iff** no active taker bond remains on it. Returns `true` +/// when the transition was applied, `false` when it was a no-op. /// -/// Used by `on_bond_invoice_canceled` (when LND cancels the only -/// outstanding bond's hold invoice) and by the taker self-cancel path -/// in `cancel.rs` (when the sender was the last bonded taker). Both -/// call sites need the same "drop back to Pending if empty" logic; -/// extracting it keeps them consistent. +/// This is the load-bearing, side-effect-free core of +/// [`maybe_drop_waiting_taker_bond`] (the latter adds the NIP-33 +/// republish, which needs process-wide keys). Splitting it out lets the +/// CAS semantics be unit-tested directly instead of via an inlined SQL +/// copy that could drift from production. /// /// Race-free: the status check, active-bond check, and status update /// run in a single conditional `UPDATE … WHERE … AND NOT EXISTS (…)` @@ -874,19 +1155,23 @@ async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), /// `WaitingTakerBond` (winner promotes to `WaitingPayment`, maker /// cancels, etc.) is caught by the `status = 'waiting-taker-bond'` /// predicate. -pub(crate) async fn maybe_drop_waiting_taker_bond( +/// +/// Phase 5: the active-bond check is scoped to `role = 'taker'`. Under +/// `apply_to = both` the order carries a `Locked` *maker* bond for the +/// whole trade; counting it here would make `NOT EXISTS` permanently +/// false, so a `WaitingTakerBond` order whose last taker bond was just +/// cancelled would never drop back to `Pending`. The drop-to-Pending +/// decision depends solely on whether any *taker* is still racing. +pub(crate) async fn drop_waiting_taker_bond_to_pending( pool: &Pool, order_id: Uuid, -) -> Result<(), MostroError> { - // Atomic compare-and-swap on (status, no active bonds). A single - // statement so SQLite snapshots both predicates at the same point - // in time. +) -> Result { let cas = sqlx::query( "UPDATE orders SET status = ? \ WHERE id = ? AND status = ? \ AND NOT EXISTS ( \ SELECT 1 FROM bonds \ - WHERE order_id = ? AND state IN (?, ?) \ + WHERE order_id = ? AND state IN (?, ?) AND role = ? \ )", ) .bind(Status::Pending.to_string()) @@ -895,14 +1180,33 @@ pub(crate) async fn maybe_drop_waiting_taker_bond( .bind(order_id) .bind(BondState::Requested.to_string()) .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) .execute(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - if cas.rows_affected() == 0 { + Ok(cas.rows_affected() == 1) +} + +/// If `order_id` is currently in `Status::WaitingTakerBond` and has no +/// remaining active taker bond, transition it back to `Status::Pending` +/// and republish the NIP-33 event. No-op otherwise. +/// +/// Used by `on_bond_invoice_canceled` (when LND cancels the only +/// outstanding bond's hold invoice) and by the taker self-cancel path +/// in `cancel.rs` (when the sender was the last bonded taker). Both +/// call sites need the same "drop back to Pending if empty" logic; +/// extracting it keeps them consistent. The CAS itself lives in +/// [`drop_waiting_taker_bond_to_pending`]; this wrapper adds the +/// NIP-33 republish. +pub(crate) async fn maybe_drop_waiting_taker_bond( + pool: &Pool, + order_id: Uuid, +) -> Result<(), MostroError> { + if !drop_waiting_taker_bond_to_pending(pool, order_id).await? { // Either the order is no longer in `WaitingTakerBond` // (winner / maker-cancel / admin already moved it on), or a - // concurrent bond is still racing. Either way we have no + // concurrent taker bond is still racing. Either way we have no // status transition to publish. return Ok(()); } @@ -1058,6 +1362,18 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); + // cashu escrow columns (mostro-core 0.12.1) — `Order::by_id` SELECTs + // them. Apply each ALTER separately for the same reason as dev_fee. + for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--"))) + { + sqlx::query(stmt) + .execute(&pool) + .await + .expect("cashu escrow migration"); + } pool } @@ -1082,6 +1398,33 @@ mod tests { b } + /// Mirror of the production taker first-to-lock-wins CAS in + /// `on_bond_invoice_accepted` (including the `role = 'taker'` filter on + /// the `NOT EXISTS` guard). Returns `rows_affected`. Kept in lockstep + /// with the real query so the race tests verify the actual semantics. + async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { + sqlx::query( + "UPDATE bonds SET state = ?, locked_at = ? \ + WHERE id = ? AND state = ? \ + AND NOT EXISTS ( \ + SELECT 1 FROM bonds b2 \ + WHERE b2.order_id = ? AND b2.state = ? AND b2.role = ? AND b2.id != ? \ + )", + ) + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .bind(bond.order_id) + .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) + .bind(bond.id) + .execute(pool) + .await + .unwrap() + .rows_affected() + } + #[tokio::test] async fn release_bond_is_idempotent_for_terminal_states() { let pool = setup_pool().await; @@ -1153,6 +1496,131 @@ mod tests { assert!(!taker_bond_required()); } + #[test] + fn maker_bond_required_is_false_without_config() { + // Phase 5: same inertness guarantee for the maker gate. With no + // `[anti_abuse_bond]` block, `publish_order` must never park an + // order at `WaitingMakerBond` or request a maker bond. + assert!(!maker_bond_required()); + } + + #[test] + fn locked_maker_bond_does_not_commit_the_trade() { + // Phase 5 (load-bearing): under `apply_to = both` every published + // order already carries a `Locked` maker bond. The take handlers' + // committed-trade gate must NOT count it — otherwise the first + // taker is wrongly rejected with `PendingOrderExists` on every + // bond-enabled order. Only a `Locked` *taker* bond commits the + // trade (first-to-lock-wins, §6.5). + let order_id = Uuid::new_v4(); + let mut maker = Bond::new_requested(order_id, "a".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + // A still-racing taker bond (Requested) must also not commit. + let taker_requested = Bond::new_requested(order_id, "b".repeat(64), BondRole::Taker, 1_000); + + assert!( + !trade_committed_by_locked_taker_bond(&[maker.clone(), taker_requested.clone()]), + "a Locked maker bond + a Requested taker bond must not commit the trade" + ); + + // Once a taker bond reaches Locked, the trade IS committed. + let mut taker_locked = taker_requested; + taker_locked.state = BondState::Locked.to_string(); + assert!( + trade_committed_by_locked_taker_bond(&[maker, taker_locked]), + "a Locked taker bond commits the trade" + ); + } + + #[test] + fn empty_bond_set_does_not_commit_the_trade() { + // Defensive: the no-bonds case (feature just enabled, or all + // bonds released) must read as "not committed" so takes proceed. + assert!(!trade_committed_by_locked_taker_bond(&[])); + } + + #[tokio::test] + async fn maker_bond_lock_is_singleton_and_idempotent() { + // Phase 5: the maker bond is a singleton, so `on_maker_bond_accepted` + // uses a plain `Requested → Locked` CAS (no concurrent-bonds + // `NOT EXISTS` guard). The first firing locks it; a duplicate + // firing (LND redelivery / restart resubscriber) affects zero + // rows because the row is no longer `Requested`, and the bond + // stays `Locked` exactly once. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + let mut bond = Bond::new_requested(order_id, "d".repeat(64), BondRole::Maker, 1_000); + bond.hash = Some("e".repeat(64)); + let bond = create_bond(&pool, bond).await.unwrap(); + + async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .execute(pool) + .await + .unwrap() + .rows_affected() + } + + assert_eq!(try_lock(&pool, &bond).await, 1, "first lock wins"); + assert_eq!( + try_lock(&pool, &bond).await, + 0, + "duplicate firing is a no-op" + ); + + let after = find_bond_by_hash(&pool, &"e".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(after.state, BondState::Locked.to_string()); + assert_eq!(after.role, BondRole::Maker.to_string()); + } + + #[tokio::test] + async fn maker_bond_lock_does_not_touch_other_bonds() { + // The maker lock CAS is keyed by `id`, so it must flip only the + // maker row even when an unrelated bond row exists on the same + // order. Guards against a future refactor accidentally widening + // the predicate. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + let mut maker = Bond::new_requested(order_id, "d".repeat(64), BondRole::Maker, 1_000); + maker.hash = Some("e".repeat(64)); + let maker = create_bond(&pool, maker).await.unwrap(); + + let mut other = Bond::new_requested(order_id, "f".repeat(64), BondRole::Taker, 1_000); + other.hash = Some("0".repeat(64)); + let other = create_bond(&pool, other).await.unwrap(); + + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(maker.id) + .bind(BondState::Requested.to_string()) + .execute(&pool) + .await + .unwrap(); + + let other_after = find_bond_by_hash(&pool, &"0".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!( + other_after.state, + BondState::Requested.to_string(), + "unrelated bond must stay Requested" + ); + assert_eq!(other_after.id, other.id); + } + #[tokio::test] async fn lock_race_guard_admits_only_one_winner() { // With bonds A and B both Requested on the same order, the @@ -1175,29 +1643,6 @@ mod tests { b.pubkey = "b".repeat(64); let bond_b = create_bond(&pool, b).await.unwrap(); - // Helper that runs the same SQL as `on_bond_invoice_accepted`. - async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { - sqlx::query( - "UPDATE bonds SET state = ?, locked_at = ? \ - WHERE id = ? AND state = ? \ - AND NOT EXISTS ( \ - SELECT 1 FROM bonds b2 \ - WHERE b2.order_id = ? AND b2.state = ? AND b2.id != ? \ - )", - ) - .bind(BondState::Locked.to_string()) - .bind(Utc::now().timestamp()) - .bind(bond.id) - .bind(BondState::Requested.to_string()) - .bind(bond.order_id) - .bind(BondState::Locked.to_string()) - .bind(bond.id) - .execute(pool) - .await - .unwrap() - .rows_affected() - } - // A goes first and wins. assert_eq!(try_lock(&pool, &bond_a).await, 1); // B's UPDATE sees A already Locked → guarded out. @@ -1213,6 +1658,129 @@ mod tests { .any(|(id, s)| *id == bond_b.id && s == &BondState::Requested.to_string())); } + #[tokio::test] + async fn locked_maker_bond_does_not_block_taker_lock_race() { + // Phase 5 regression (apply_to = both): a `Locked` maker bond is + // present on every published order. The taker first-to-lock-wins + // CAS must IGNORE it (its `NOT EXISTS` guard is scoped to + // `role = 'taker'`), so the first taker to pay still wins. Without + // the role filter the maker bond would satisfy the guard, the + // taker's UPDATE would affect zero rows, and every taker would be + // wrongly rejected as a race loser. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + // Maker bond already Locked (the steady state after publication). + let mut maker = Bond::new_requested(order_id, "m".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + maker.hash = Some("d".repeat(64)); + let maker = create_bond(&pool, maker).await.unwrap(); + + // A taker now pays their bond. + let mut taker = make_bond(order_id, BondState::Requested); + taker.pubkey = "t".repeat(64); + taker.hash = Some("e".repeat(64)); + let taker = create_bond(&pool, taker).await.unwrap(); + + assert_eq!( + try_lock(&pool, &taker).await, + 1, + "taker must win the lock race despite the Locked maker bond" + ); + + // Maker bond untouched; taker bond now Locked. + let maker_after = find_bond_by_hash(&pool, &"d".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(maker_after.id, maker.id); + assert_eq!(maker_after.state, BondState::Locked.to_string()); + let taker_after = find_bond_by_hash(&pool, &"e".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(taker_after.id, taker.id); + assert_eq!(taker_after.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn maybe_drop_waiting_taker_bond_ignores_locked_maker_bond() { + // Phase 5 regression (apply_to = both): when the last taker bond is + // cancelled, the order must drop from `WaitingTakerBond` back to + // `Pending` even though the maker's `Locked` bond is still on the + // order. The CAS's active-bond check is scoped to `role = 'taker'`, + // so the lingering maker bond does not pin the order in + // `WaitingTakerBond`. Exercises the real CAS helper + // (`drop_waiting_taker_bond_to_pending`) — not an inlined SQL copy + // — so the test tracks production semantics. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::WaitingTakerBond.to_string()) + .bind(order_id) + .execute(&pool) + .await + .unwrap(); + + // Only a Locked maker bond remains (the taker bond was already + // released by the caller before maybe_drop runs). + let mut maker = Bond::new_requested(order_id, "m".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + maker.hash = Some("d".repeat(64)); + create_bond(&pool, maker).await.unwrap(); + + let dropped = drop_waiting_taker_bond_to_pending(&pool, order_id) + .await + .unwrap(); + assert!( + dropped, + "order must drop to Pending: the Locked maker bond must not count as an active taker bond" + ); + + let status: String = sqlx::query_scalar("SELECT status FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, Status::Pending.to_string()); + } + + #[tokio::test] + async fn drop_waiting_taker_bond_held_by_active_taker_bond() { + // Counterpart to the maker-bond test: a still-active *taker* bond + // (Requested) MUST pin the order in `WaitingTakerBond` — the CAS + // returns false and the status is unchanged. Confirms the role + // filter does not over-drop while another taker is still racing. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::WaitingTakerBond.to_string()) + .bind(order_id) + .execute(&pool) + .await + .unwrap(); + + let mut taker = make_bond(order_id, BondState::Requested); + taker.pubkey = "t".repeat(64); + taker.hash = Some("e".repeat(64)); + create_bond(&pool, taker).await.unwrap(); + + let dropped = drop_waiting_taker_bond_to_pending(&pool, order_id) + .await + .unwrap(); + assert!(!dropped, "an active taker bond must keep the order parked"); + + let status: String = sqlx::query_scalar("SELECT status FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, Status::WaitingTakerBond.to_string()); + } + #[tokio::test] async fn concurrent_requested_bonds_coexist() { // Multiple Requested bonds on the same order coexist — they diff --git a/src/app/bond/mod.rs b/src/app/bond/mod.rs index ce7f7a07..981a79bc 100644 --- a/src/app/bond/mod.rs +++ b/src/app/bond/mod.rs @@ -18,8 +18,10 @@ pub mod slash; pub mod types; pub use flow::{ - release_bond, release_bonds_for_order, release_bonds_for_order_or_warn, request_taker_bond, - resubscribe_active_bonds, taker_bond_required, TakerContext, + maker_bond_required, release_bond, release_bonds_for_order, release_bonds_for_order_or_warn, + release_taker_bonds_for_order_or_warn, request_maker_bond, request_taker_bond, + resubscribe_active_bonds, taker_bond_required, trade_committed_by_locked_taker_bond, + TakerContext, }; pub use math::{compute_bond_amount, compute_node_share}; pub use model::Bond; diff --git a/src/app/bond/payout.rs b/src/app/bond/payout.rs index 7ec72ef4..c432f614 100644 --- a/src/app/bond/payout.rs +++ b/src/app/bond/payout.rs @@ -1524,6 +1524,18 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); + // cashu escrow columns (mostro-core 0.12.1) — `Order::by_id` SELECTs + // them. Apply each ALTER separately for the same reason as dev_fee. + for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--"))) + { + sqlx::query(stmt) + .execute(&pool) + .await + .expect("cashu escrow migration"); + } pool } diff --git a/src/app/bond/slash.rs b/src/app/bond/slash.rs index 1ca0887d..a51ab633 100644 --- a/src/app/bond/slash.rs +++ b/src/app/bond/slash.rs @@ -48,17 +48,19 @@ use mostro_core::error::{ ServiceError, }; use mostro_core::message::{Action, BondResolution, Message, Payload}; -use mostro_core::order::{Order, SmallOrder, Status}; +use mostro_core::order::{Kind, Order, SmallOrder, Status}; use nostr_sdk::prelude::PublicKey; use sqlx::{Pool, Sqlite}; use tracing::{info, warn}; use uuid::Uuid; use super::db::find_active_bonds_for_order; -use super::flow::{release_bond, release_bonds_for_order_or_warn}; +use super::flow::{ + release_bond, release_bonds_for_order_or_warn, release_taker_bonds_for_order_or_warn, +}; use super::math::compute_node_share; use super::model::Bond; -use super::types::{BondSlashReason, BondState}; +use super::types::{BondRole, BondSlashReason, BondState}; use crate::config::settings::Settings; use crate::config::types::AntiAbuseBondSettings; use crate::lightning::LndConnector; @@ -293,6 +295,36 @@ pub async fn apply_bond_resolution( /// config (the scheduler passes `Settings::get_bond()`); it is taken as a /// parameter rather than read from the global so the gate is unit-testable /// without mutating process-wide state. +/// Does a waiting-state timeout on this order **republish** it (return it +/// to the book in `Pending`) rather than cancel it outright? +/// +/// Mirrors the republish branch of `scheduler::job_cancel_orders`: +/// `(WaitingBuyerInvoice, Sell)` and `(WaitingPayment, Buy)` republish — +/// in both the responsible party is the *taker*, so the maker stays +/// committed and the order goes back to the book. The complementary +/// `(WaitingBuyerInvoice, Buy)` / `(WaitingPayment, Sell)` cases cancel the +/// order (the responsible party is the *maker*). Keep this in sync with the +/// scheduler's match. +fn order_republishes_on_timeout(order: &Order) -> bool { + matches!( + (order.get_order_status(), order.get_order_kind()), + (Ok(Status::WaitingBuyerInvoice), Ok(Kind::Sell)) + | (Ok(Status::WaitingPayment), Ok(Kind::Buy)) + ) +} + +/// Release the still-active bonds on a timed-out order, honouring the +/// republish-vs-cancel distinction: on a republish the maker's `Locked` +/// bond is retained (it follows the order's lifecycle), otherwise every +/// bond is released. +async fn release_on_timeout(pool: &Pool, order_id: Uuid, republishes: bool) { + if republishes { + release_taker_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await; + } else { + release_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await; + } +} + pub async fn slash_or_release_on_timeout( pool: &Pool, ln_client: &mut L, @@ -311,14 +343,26 @@ pub async fn slash_or_release_on_timeout( } }; + // Will this timeout **republish** the order (return it to the book) or + // **terminate** it? When the taker is the responsible party the order + // goes back to `Pending` and is republished, so the maker's commitment + // survives — its bond stays `Locked` and is resolved only when the + // order itself terminates (completed / cancelled / `Pending` expiry). + // Only the abandoning taker side is settled here. When the maker is + // responsible the order is cancelled outright, so every bond is + // released. This mirrors `scheduler::job_cancel_orders`' own + // republish-vs-cancel split (keep the two in sync). + let republishes = order_republishes_on_timeout(order); + // Gate the slash. `apply_to` is a posting-timing switch; Phase 4 is // taker-only, so we check `applies_to_taker` (Phase 7 widens this to // the maker). When the gate is closed we still release — bonds left - // over from a prior enabled period must drain regardless. + // over from a prior enabled period must drain regardless (but a + // republish still retains the maker bond). let slash_armed = bond_cfg .is_some_and(|c| c.enabled && c.slash_on_waiting_timeout && c.apply_to.applies_to_taker()); if !slash_armed { - release_bonds_for_order_or_warn(pool, order.id, "scheduler_timeout").await; + release_on_timeout(pool, order.id, republishes).await; return Ok(None); } @@ -327,38 +371,50 @@ pub async fn slash_or_release_on_timeout( let Some(responsible) = resolve_locked_bond(order, &bonds, side).cloned() else { // Responsible party has no bond (e.g. the maker under // `apply_to = take`), or the bond already moved out of `Locked`. - // No slash; release whatever is still active on the order. - release_bonds_for_order_or_warn(pool, order.id, "scheduler_timeout").await; + // No slash; release whatever is still active on the order + // (retaining the maker bond on a republish). + release_on_timeout(pool, order.id, republishes).await; return Ok(None); }; - // Reuse the Phase 2 primitive. The `BondResolution` names the - // responsible side; `apply_bond_resolution` settles that bond's HTLC - // + CAS → PendingPayout(Timeout) and releases every other active bond - // on the order (the Phase 1 cancel path). - let resolution = match side { - Side::Buyer => BondResolution { - slash_seller: false, - slash_buyer: true, - }, - Side::Seller => BondResolution { - slash_seller: true, - slash_buyer: false, - }, - }; - apply_bond_resolution( + // Settle the responsible bond's HTLC + CAS → PendingPayout(Timeout) + // (the Phase 2 `slash_one` primitive), then resolve the remaining + // active bonds: release them — but on a republish retain the maker's + // still-`Locked` bond, which the abandoning taker's timeout must not + // disturb. (We intentionally do **not** route this through + // `apply_bond_resolution`, which always releases every non-slashed + // bond — that is correct for a terminal dispute resolution but would + // wrongly release the maker on a republish.) + let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct); + slash_one( pool, ln_client, - order, - &resolution, + &responsible, BondSlashReason::Timeout, + node_share_pct, ) - .await?; + .await; + let maker = BondRole::Maker.to_string(); + for bond in bonds.iter() { + if bond.id == responsible.id { + continue; + } + if republishes && bond.role == maker { + continue; + } + if let Err(e) = release_bond(pool, bond).await { + warn!( + bond_id = %bond.id, + order_id = %order.id, + "scheduler_timeout: release_bond failed: {}", e + ); + } + } // Confirm the slash actually landed before claiming it: a transient - // settle failure leaves the bond `Locked` (apply_bond_resolution is - // best-effort), and we must never tell a user their bond was forfeited - // while the HTLC is still theirs. + // settle failure leaves the bond `Locked` (`slash_one` is best-effort), + // and we must never tell a user their bond was forfeited while the HTLC + // is still theirs. if timeout_slash_confirmed(pool, responsible.id).await? { info!( bond_id = %responsible.id, @@ -737,7 +793,21 @@ mod tests { pubkey: &str, state: BondState, ) -> Bond { - let mut b = Bond::new_requested(order_id, pubkey.to_string(), BondRole::Taker, 10_000); + insert_bond_with_role(pool, order_id, pubkey, BondRole::Taker, state).await + } + + /// Phase 5: same fixture as [`insert_bond`] but parameterised on the + /// posting role, so maker-bond dispute-slash tests can assert the + /// buyer/seller → bond-row resolution resolves to the maker row when + /// the maker is on the named side (§3.1). + async fn insert_bond_with_role( + pool: &Pool, + order_id: Uuid, + pubkey: &str, + role: BondRole, + state: BondState, + ) -> Bond { + let mut b = Bond::new_requested(order_id, pubkey.to_string(), role, 10_000); b.state = state.to_string(); b.preimage = Some(stub_preimage()); // No hash → release_bond skips the LND cancel branch entirely @@ -945,6 +1015,120 @@ mod tests { assert_eq!(row.3, Some(0)); } + #[tokio::test] + async fn apply_slash_seller_on_sell_order_transitions_maker_bond() { + // Phase 5 (§10.2 / §10.4 acceptance bullet 3): on a sell-order + // the maker IS the seller, so `slash_seller=true` must resolve to + // the maker's bond row via the §3.1 pubkey mapping — even though + // the resolver is role-agnostic and matches on + // `order.seller_pubkey`. With both a maker bond (seller) and a + // taker bond (buyer) posted, slashing only the seller transitions + // the maker bond to PendingPayout and releases the taker bond, + // proving the two sides resolve orthogonally. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + + // Pre-flight validation must accept the directive: the seller + // (maker) holds a Locked bond. + validate_bond_resolution(&pool, &order, &res).await.unwrap(); + + apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + let maker_row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + maker_row.0, + BondState::PendingPayout.to_string(), + "maker bond must be slashed on slash_seller for a sell order" + ); + assert_eq!(maker_row.1.as_deref(), Some("lost-dispute")); + + let taker_state: String = sqlx::query_scalar("SELECT state FROM bonds WHERE id = ?") + .bind(taker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + taker_state, + BondState::Released.to_string(), + "the non-slashed taker bond must be released, not settled" + ); + } + + #[tokio::test] + async fn apply_slash_buyer_on_buy_order_transitions_maker_bond() { + // Phase 5 (§3.1 mirror): on a buy-order the maker IS the buyer, + // so `slash_buyer=true` resolves to the maker's bond row. This is + // the buy-order counterpart of the sell-order test above and + // completes the §10.4 acceptance bullet 3 matrix. + let pool = setup_pool().await; + // Buy order: maker is the buyer, taker is the seller. + let order = fixture_order(Kind::Buy, taker_pk(), maker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + validate_bond_resolution(&pool, &order, &res).await.unwrap(); + apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + let row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row.0, + BondState::PendingPayout.to_string(), + "maker bond must be slashed on slash_buyer for a buy order" + ); + assert_eq!(row.1.as_deref(), Some("lost-dispute")); + } + #[tokio::test] async fn apply_is_idempotent_on_already_pending_payout() { // A duplicate admin call (or a slash CAS racing with itself) @@ -1323,6 +1507,148 @@ mod tests { assert!(row.2.unwrap() > 0, "slashed_at must be set"); } + #[tokio::test] + async fn timeout_republish_retains_maker_bond_sell_order() { + // Regression (PR #767 review): sell order, WaitingBuyerInvoice. The + // buyer (taker) times out, so the order is **republished** to the + // book — the maker (seller) is still committed to it. The taker + // bond must be slashed, but the maker bond must stay `Locked`: + // releasing it would put a takeable order back in the book with no + // maker bond backing it. It is only released when the order itself + // terminates. + let pool = setup_pool().await; + let order = waiting_order( + Kind::Sell, + maker_pk(), + taker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + // apply_to=both so a maker bond is in play alongside the taker bond. + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!( + result.map(|b| b.id), + Some(taker_bond.id), + "the abandoning taker's bond is the one slashed" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::PendingPayout.to_string(), + "taker bond is slashed on the republish path" + ); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Locked.to_string(), + "maker bond must stay Locked when the order is republished" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "only the slashed taker HTLC is settled; the maker HTLC is untouched" + ); + } + + #[tokio::test] + async fn timeout_republish_with_no_slash_still_retains_maker_bond() { + // Same republish scenario but with the slash gate closed + // (slash_on_waiting_timeout = false). The taker bond drains via the + // Phase 1 release, but the maker bond must still be retained — the + // order goes back to the book with the maker committed. + let pool = setup_pool().await; + let order = waiting_order( + Kind::Sell, + maker_pk(), + taker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, false, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none(), "gate closed → no slash reported"); + assert!(ln.calls().is_empty(), "release path never settles an HTLC"); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond is released when the slash gate is closed" + ); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Locked.to_string(), + "maker bond is retained on republish even when no slash happens" + ); + } + + #[tokio::test] + async fn timeout_cancel_releases_maker_bond_sell_order() { + // Counterpart to the republish case: sell order, WaitingPayment. + // The seller (maker) is responsible, so the order is **cancelled** + // outright (not republished). Because the order terminates, the + // maker bond must be released — the retain-on-republish carve-out + // must NOT leak into the terminal cancel path. Gate closed + // (slash_on_waiting_timeout = false) keeps this purely about the + // release routing, mirroring the republish/no-slash test above. + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, false, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none()); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Released.to_string(), + "maker bond is released when the order is cancelled (terminal)" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond is released too on a terminal cancel" + ); + } + #[tokio::test] async fn timeout_slash_buy_seller_silent_slashes_taker_bond() { // buy order, WaitingPayment: the seller is responsible and on a diff --git a/src/app/cancel.rs b/src/app/cancel.rs index f1199ce6..fe0ba83b 100644 --- a/src/app/cancel.rs +++ b/src/app/cancel.rs @@ -235,8 +235,16 @@ async fn cancel_order_by_taker( // Look at what's left on the order. If other concurrent takers // still have active bonds, do NOT reset the order — they are // still racing. Just message the sender that their take is cancelled. + // + // Phase 5: scope this to *taker* bonds. Under `apply_to = both` the + // order also carries a `Locked` maker bond (pubkey != the cancelling + // taker), which must not count as "another taker still racing" — that + // would wrongly keep the order in `WaitingTakerBond` and prevent it + // from dropping back to `Pending` when the last taker backs out. let remaining = crate::app::bond::db::find_active_bonds_for_order(pool, order_id).await?; - let others_remain = remaining.iter().any(|b| b.pubkey != sender_str); + let others_remain = remaining + .iter() + .any(|b| b.pubkey != sender_str && b.role == crate::app::bond::BondRole::Taker.to_string()); if others_remain { enqueue_order_msg( request_id, diff --git a/src/app/dev_fee.rs b/src/app/dev_fee.rs index 2c294f89..1c002e09 100644 --- a/src/app/dev_fee.rs +++ b/src/app/dev_fee.rs @@ -1070,6 +1070,12 @@ mod tests { .execute(&pool) .await .expect("Failed to apply dev_fee migration"); + sqlx::query(include_str!( + "../../migrations/20260530120000_cashu_escrow_fields.sql" + )) + .execute(&pool) + .await + .expect("Failed to apply cashu escrow migration"); pool } diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 0ce04b8b..638377a5 100644 --- a/src/app/take_buy.rs +++ b/src/app/take_buy.rs @@ -53,8 +53,8 @@ pub async fn take_buy_action( // handler doesn't release prior bonds at retake-time anymore — // multiple `Requested` taker bonds coexist on the order and the // first to reach `Locked` wins. We still need three guards here: - // 1. A `Locked` bond already on the order means the trade is - // committed; reject with `PendingOrderExists`. + // 1. A `Locked` *taker* bond already on the order means the + // trade is committed; reject with `PendingOrderExists`. // 2. The sender's own pubkey already has a `Requested` bond on // this order → idempotent retry: re-send the same // `PayInvoice` message and return. @@ -63,13 +63,16 @@ pub async fn take_buy_action( // path; that context lives on the bond row's `taker_*` columns // until the winning bond locks and // `on_bond_invoice_accepted` promotes it onto the order. + // + // Phase 5: with `apply_to = both` the maker's own bond is already + // `Locked` on every published order — that is the normal state, not + // a committed trade. The committed-trade gate must therefore only + // count `Locked` *taker* bonds; otherwise a locked maker bond would + // wrongly block every taker with `PendingOrderExists`. let bond_required = bond::taker_bond_required(); if bond_required { let active = crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await?; - if active - .iter() - .any(|b| b.state == crate::app::bond::BondState::Locked.to_string()) - { + if bond::trade_committed_by_locked_taker_bond(&active) { return Err(MostroCantDo(CantDoReason::PendingOrderExists)); } let sender_str = event.sender.to_string(); diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 804915e1..62b059a4 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -77,8 +77,8 @@ pub async fn take_sell_action( // multiple `Requested` taker bonds coexist on the order and the // first to reach `Locked` wins. Three guards before this take // proceeds: - // 1. A `Locked` bond already on the order means the trade is - // committed; reject with `PendingOrderExists`. + // 1. A `Locked` *taker* bond already on the order means the + // trade is committed; reject with `PendingOrderExists`. // 2. The sender's own pubkey already has a `Requested` bond on // this order → idempotent retry: re-send the same // `PayInvoice` message and return. @@ -87,13 +87,16 @@ pub async fn take_sell_action( // The order's taker fields are not mutated under the bond path — // they're stashed on the bond row's `taker_*` columns until the // winning bond locks. + // + // Phase 5: with `apply_to = both` the maker's own bond is already + // `Locked` on every published order — that is the normal state, not + // a committed trade. The committed-trade gate must therefore only + // count `Locked` *taker* bonds; otherwise a locked maker bond would + // wrongly block every taker with `PendingOrderExists`. let bond_required = bond::taker_bond_required(); if bond_required { let active = crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await?; - if active - .iter() - .any(|b| b.state == crate::app::bond::BondState::Locked.to_string()) - { + if bond::trade_committed_by_locked_taker_bond(&active) { return Err(MostroCantDo(CantDoReason::PendingOrderExists)); } let sender_str = event.sender.to_string(); diff --git a/src/db.rs b/src/db.rs index c0120edb..7b4c884e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -593,12 +593,19 @@ pub async fn find_order_by_date(pool: &SqlitePool) -> Result, MostroE // `waiting-taker-bond` here, an order parked at that status past its // `expires_at` would never expire and the bond HTLCs would tie up // taker funds in LND until CLTV expiry. + // + // Phase 5: `waiting-maker-bond` is the maker-side analogue — an order + // whose maker never paid the bond, so it was never published to + // Nostr at all. It must also expire here, otherwise the abandoned + // order row and its bond HTLC linger until CLTV. Unlike the other two + // buckets this status has no NIP-33 event, so the expiry job skips the + // Nostr republish for it (see `job_expire_pending_older_orders`). let order = sqlx::query_as::<_, Order>( r#" SELECT * FROM orders WHERE expires_at < ?1 - AND status IN ('pending', 'waiting-taker-bond') + AND status IN ('pending', 'waiting-taker-bond', 'waiting-maker-bond') "#, ) .bind(expire_time.as_secs() as i64) @@ -1356,7 +1363,10 @@ mod tests { next_trade_index integer default 0, dev_fee integer default 0, dev_fee_paid integer not null default 0, - dev_fee_payment_hash char(64) + dev_fee_payment_hash char(64), + cashu_mint_url text, + cashu_escrow_token text, + cashu_escrow_locked_at integer ) "#, ) @@ -1764,8 +1774,10 @@ mod tests { let pending_expired = uuid::Uuid::new_v4(); let waiting_taker_bond_expired = uuid::Uuid::new_v4(); + let waiting_maker_bond_expired = uuid::Uuid::new_v4(); let pending_fresh = uuid::Uuid::new_v4(); let waiting_taker_bond_fresh = uuid::Uuid::new_v4(); + let waiting_maker_bond_fresh = uuid::Uuid::new_v4(); let active_expired = uuid::Uuid::new_v4(); // out-of-bucket; must not match insert(&pool, pending_expired, "pending", past).await; @@ -1776,6 +1788,13 @@ mod tests { past, ) .await; + insert( + &pool, + waiting_maker_bond_expired, + "waiting-maker-bond", + past, + ) + .await; insert(&pool, pending_fresh, "pending", future).await; insert( &pool, @@ -1784,6 +1803,13 @@ mod tests { future, ) .await; + insert( + &pool, + waiting_maker_bond_fresh, + "waiting-maker-bond", + future, + ) + .await; insert(&pool, active_expired, "active", past).await; let expired = super::find_order_by_date(&pool).await.unwrap(); @@ -1797,6 +1823,10 @@ mod tests { ids.contains(&waiting_taker_bond_expired), "expired WaitingTakerBond must be returned (Phase 1.5)" ); + assert!( + ids.contains(&waiting_maker_bond_expired), + "expired WaitingMakerBond must be returned (Phase 5)" + ); assert!( !ids.contains(&pending_fresh), "non-expired Pending must NOT be returned" @@ -1805,6 +1835,10 @@ mod tests { !ids.contains(&waiting_taker_bond_fresh), "non-expired WaitingTakerBond must NOT be returned" ); + assert!( + !ids.contains(&waiting_maker_bond_fresh), + "non-expired WaitingMakerBond must NOT be returned" + ); assert!( !ids.contains(&active_expired), "expired but non-pre-trade orders (e.g. active) must NOT be returned" diff --git a/src/nip33.rs b/src/nip33.rs index 63b349dc..f26bfaa8 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -1074,6 +1074,26 @@ mod tests { ); } + /// Phase 5 (`docs/ANTI_ABUSE_BOND.md` §10.1 / §10.4): an order whose + /// daemon-internal status is `WaitingMakerBond` has **not** been + /// published to Nostr yet — the maker's bond is still outstanding. + /// `create_status_tags` must therefore signal "do not emit an event" + /// (`create_event == false`), so the order never appears in the book + /// until the bond locks and the order transitions to `Pending`. This + /// is the opposite of `WaitingTakerBond`, which is already advertised + /// and must keep emitting. + #[test] + fn waiting_maker_bond_is_not_published_on_wire() { + let mut order = make_pending_order(); + order.status = Status::WaitingMakerBond.to_string(); + + let (emit, _mapped) = create_status_tags(&order).expect("status tags"); + assert!( + !emit, + "WaitingMakerBond must NOT emit an order event — the order is invisible until the bond locks" + ); + } + /// Sanity: the existing `Pending` mapping behaves identically. If /// somebody refactors `create_status_tags` the bucket-equivalence /// between `Pending` and `WaitingTakerBond` must not drift. diff --git a/src/scheduler.rs b/src/scheduler.rs index 8bcb1573..931302fc 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -521,6 +521,38 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { order.id, order.created_at ); + + // Phase 5: a `WaitingMakerBond` order was never + // published to Nostr (the maker abandoned the bond + // invoice), so there is no NIP-33 event to replace. + // Going through `update_order_event` would publish a + // brand-new Expired/Canceled event for an order that + // never appeared in the book — a ghost entry the + // §10.4 acceptance forbids. Mark it Expired directly + // in the DB and release any bond row instead. + if order.status == Status::WaitingMakerBond.to_string() { + let order_id = order.id; + let mut expired = order.clone(); + expired.status = Status::Expired.to_string(); + match expired.update(pool).await { + Ok(_) => { + bond::release_bonds_for_order_or_warn( + pool, + order_id, + "maker_bond_expiry", + ) + .await; + } + Err(e) => { + tracing::warn!( + "maker_bond_expiry: persist failed for order {} ({}); skipping bond release — will retry next tick", + order_id, e + ); + } + } + continue; + } + // We update the order id with the new event_id if let Ok(order_updated) = crate::util::update_order_event(&keys, Status::Expired, order).await diff --git a/src/util.rs b/src/util.rs index eb545e1a..56de07ca 100644 --- a/src/util.rs +++ b/src/util.rs @@ -363,7 +363,7 @@ pub async fn publish_order( trade_index: Option, ) -> Result<(), MostroError> { // Prepare a new default order - let new_order_db = match prepare_new_order( + let mut new_order_db = match prepare_new_order( new_order, initiator_pubkey, trade_index, @@ -378,19 +378,138 @@ pub async fn publish_order( } }; + // Phase 5: when the maker side is bonded, the order must NOT hit the + // order book until the maker locks an anti-abuse bond. Park it at + // `WaitingMakerBond` (no NIP-33 event emitted), request the bond, and + // defer the publication to `resume_publish_after_maker_bond`, which + // the bond subscriber calls on `Accepted`. Range makers are deferred + // to Phase 6 (parent/child proportional slashes), so they keep + // publishing immediately for now. + let maker_bond_required = crate::app::bond::maker_bond_required(); + if maker_bond_required && new_order_db.is_range_order() { + // Visibility for operators: with `apply_to ∈ { make, both }` a + // range order is published WITHOUT a maker bond because + // proportional range-bond sizing/slashing is Phase 6. Without + // this log the operator could wrongly assume every order on a + // bond-enabled node is bonded. + tracing::warn!( + order_kind = %new_order_db.kind, + "publish_order: maker bond is enabled but order {} is a range order — \ + publishing WITHOUT a maker bond (range maker bonds land in Phase 6)", + new_order_db.id + ); + } + if maker_bond_required && !new_order_db.is_range_order() { + let notional = maker_bond_notional_sats(&new_order_db)?; + new_order_db.status = Status::WaitingMakerBond.to_string(); + let order = new_order_db + .create(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + info!("New order saved (awaiting maker bond) Id: {}", order.id); + if let Err(e) = crate::app::bond::request_maker_bond( + pool, + &order, + trade_pubkey, + notional, + request_id, + trade_index, + ) + .await + { + // The order was parked at `WaitingMakerBond` but never emitted + // a NIP-33 event, and `request_maker_bond` already released any + // bond row it managed to create. Without cleanup the row would + // sit hidden in `WaitingMakerBond` until the order-expiry job + // reaps it hours later. Delete the stranded row now (scoped to + // the parked status so we never touch one that has since + // advanced) and surface the error to the maker. + tracing::warn!( + order_id = %order.id, + "publish_order: request_maker_bond failed ({}); deleting stranded WaitingMakerBond order", + e + ); + if let Err(del) = sqlx::query("DELETE FROM orders WHERE id = ? AND status = ?") + .bind(order.id) + .bind(Status::WaitingMakerBond.to_string()) + .execute(pool) + .await + { + tracing::warn!( + order_id = %order.id, + "publish_order: failed to delete stranded order: {}", del + ); + } + return Err(e); + } + return Ok(()); + } + // CRUD order creation - let mut order = new_order_db - .clone() + let order = new_order_db .create(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + info!("New order saved Id: {}", order.id); + + finalize_order_publication( + pool, + keys, + order, + identity_pubkey, + trade_pubkey, + request_id, + trade_index, + ) + .await +} + +/// Sats notional a maker bond is sized against (Phase 5, non-range only). +/// +/// Fixed-price orders carry their sats `amount` directly. Market-priced +/// single orders have `amount == 0` at creation, so we convert the fiat +/// amount at the current cached price — the same quote +/// `calculate_and_check_quote` validates against at order time. The bond +/// is a one-time snapshot and is not repriced if the market moves before +/// the order is taken (spec §10.3). Range orders never reach this path in +/// Phase 5; their `max_amount`-based sizing lands in Phase 6. +fn maker_bond_notional_sats(order: &Order) -> Result { + if order.amount > 0 { + return Ok(order.amount); + } + let price = get_bitcoin_price(&order.fiat_code)?; + if price <= 0.0 { + return Err(MostroInternalErr(ServiceError::NoAPIResponse)); + } + let sats = (order.fiat_amount as f64 / price) * 1E8; + Ok(sats as i64) +} + +/// Publish the NIP-33 event for a freshly-persisted order, persist its +/// `event_id`, ack the maker with [`Action::NewOrder`], and broadcast. +/// +/// Shared by the inline `publish_order` path (no maker bond) and the +/// deferred [`resume_publish_after_maker_bond`] path (maker bond locked). +/// The order row must already exist in the DB; on success it is in +/// `Status::Pending` with its `event_id` set. +async fn finalize_order_publication( + pool: &SqlitePool, + keys: &Keys, + mut order: Order, + identity_pubkey: PublicKey, + trade_pubkey: PublicKey, + request_id: Option, + trade_index: Option, +) -> Result<(), MostroError> { let order_id = order.id; - info!("New order saved Id: {}", order_id); + // The maker-bond path parked the order at `WaitingMakerBond`; the + // no-bond path created it at `Pending`. Either way it goes live now. + order.status = Status::Pending.to_string(); // Get tags for new order in case of full privacy or normal order // nip33 kind with order fields as tags and order id as identifier (kind 38383 for orders) let event = if let Some(tags) = - get_tags_for_new_order(&new_order_db, pool, &identity_pubkey, &trade_pubkey, keys).await? + get_tags_for_new_order(&order, pool, &identity_pubkey, &trade_pubkey, keys).await? { new_order_event(keys, "", order_id.to_string(), tags) .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))? @@ -401,21 +520,22 @@ pub async fn publish_order( info!("Order event to be published: {event:#?}"); let event_id = event.id.to_string(); info!("Publishing Event Id: {event_id} for Order Id: {order_id}"); - // We update the order with the new event_id + // We update the order with the new event_id (and Pending status) order.event_id = event_id; + // Build the ack payload before `update` consumes the order row. + let mut small = order.as_new_order(); + small.id = Some(order_id); order .update(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - let mut order = new_order_db.as_new_order(); - order.id = Some(order_id); // Send message as ack with small order enqueue_order_msg( request_id, Some(order_id), Action::NewOrder, - Some(Payload::Order(order)), + Some(Payload::Order(small)), trade_pubkey, trade_index, ) @@ -430,6 +550,70 @@ pub async fn publish_order( .map_err(|err| MostroInternalErr(ServiceError::NostrError(err.to_string()))) } +/// Finish publishing an order whose maker bond has just locked. +/// +/// Called from the bond subscriber (`bond::flow::on_maker_bond_accepted`). +/// Derives the maker's identity and trade pubkeys from the order row — +/// the maker is the seller on a sell order, the buyer on a buy order +/// (§3.1) — and hands off to [`finalize_order_publication`]. Idempotency +/// across redeliveries is enforced by the caller, which only invokes this +/// while the order is still in `WaitingMakerBond`. +pub async fn resume_publish_after_maker_bond( + pool: &SqlitePool, + keys: &Keys, + order: Order, + request_id: Option, +) -> Result<(), MostroError> { + // Atomically claim the deferred `WaitingMakerBond → Pending` + // transition. The bond subscriber already re-read the row and saw + // `WaitingMakerBond`, but that check is not atomic with the publish + // below: the order-expiry job (`job_expire_pending_older_orders`) can + // flip the row `WaitingMakerBond → Expired` (and cancel the just-locked + // bond) in between. Without a CAS the full-row write inside + // `finalize_order_publication` would blindly resurrect the dead order + // back to `Pending` and emit a NIP-33 event for it. If the CAS affects + // 0 rows another path already owns the status, so we skip cleanly. + let cas = sqlx::query("UPDATE orders SET status = ? WHERE id = ? AND status = ?") + .bind(Status::Pending.to_string()) + .bind(order.id) + .bind(Status::WaitingMakerBond.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + if cas.rows_affected() != 1 { + info!( + "resume_publish_after_maker_bond: order {} no longer WaitingMakerBond — skipping deferred publish", + order.id + ); + return Ok(()); + } + let kind = order.get_order_kind().map_err(MostroInternalErr)?; + let (trade_pubkey, identity_pubkey, trade_index) = match kind { + OrderKind::Sell => ( + order.get_seller_pubkey().map_err(MostroInternalErr)?, + order + .get_master_seller_pubkey() + .map_err(MostroInternalErr)?, + order.trade_index_seller, + ), + OrderKind::Buy => ( + order.get_buyer_pubkey().map_err(MostroInternalErr)?, + order.get_master_buyer_pubkey().map_err(MostroInternalErr)?, + order.trade_index_buyer, + ), + }; + finalize_order_publication( + pool, + keys, + order, + identity_pubkey, + trade_pubkey, + request_id, + trade_index, + ) + .await +} + async fn prepare_new_order( new_order: &SmallOrder, initiator_pubkey: PublicKey, @@ -1382,6 +1566,12 @@ mod tests { .execute(&pool) .await .unwrap(); + sqlx::query(include_str!( + "../migrations/20260530120000_cashu_escrow_fields.sql" + )) + .execute(&pool) + .await + .unwrap(); pool } @@ -1611,4 +1801,18 @@ mod tests { let fee = calculate_dev_fee(1, 0.30); assert_eq!(fee, 0); } + + #[test] + fn maker_bond_notional_uses_fixed_amount_directly() { + // Phase 5: a fixed-price order carries its sats `amount`, so the + // maker-bond notional is exactly that — no price lookup, no API + // dependency in this path. + let order = Order { + amount: 50_000, + fiat_code: "USD".to_string(), + fiat_amount: 25, + ..Default::default() + }; + assert_eq!(maker_bond_notional_sats(&order).unwrap(), 50_000); + } } From eed5ede246446f349fc21dd4abf570fc44b1ab49 Mon Sep 17 00:00:00 2001 From: "ermeme[bot]" <284049204+ermeme[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:08:20 -0300 Subject: [PATCH 05/23] docs: document daemon event kinds (#769) - 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 --- README.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index af268686..741ee032 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,26 @@ While @lnp2pBot works excellently, it relies on Telegram—a platform potentiall - **Nostr Protocol** - NIP-59 (GiftWrap), NIP-33 (replaceable events) compliance - **Observability** - Structured logging with `tracing`, configurable log levels +### Nostr Event Kinds Used by the Daemon + +Mostro uses a small set of Nostr event kinds. Some are part of the public protocol, and some are transport-only details that live inside NIP-59 GiftWrap envelopes. + +| Kind | Name / Constant | Used for | Notes | +| --- | --- | --- | --- | +| `0` | `Metadata` | Mostro profile metadata | Standard Nostr profile event published at startup when metadata is configured. | +| `1` | `TextNote` | Inner rumor in NIP-59 | Transport-only. Mostro creates and reads it *inside* GiftWrap; it is not published as a standalone public event. | +| `13` | `Seal` | Inner sealed envelope in NIP-59 | Transport-only. Mostro creates and reads it *inside* GiftWrap; it is not published as a standalone public event. | +| `1059` | `GiftWrap` | NIP-59 outer envelope | This is the relay-visible event kind that Mostro subscribes to and publishes for wrapped messages. | +| `10002` | `RelayList` | Relay metadata | Standard Nostr relay list event published periodically by the scheduler. | +| `8383` | `DEV_FEE_AUDIT_EVENT_KIND` | Dev fee audit event | Public audit event used for transparent fee accounting. | +| `30078` | `NOSTR_EXCHANGE_RATES_EVENT_KIND` | Exchange rates | NIP-33 replaceable event for BTC/fiat rate publishing. | +| `38383` | `NOSTR_ORDER_EVENT_KIND` | Orders | NIP-33 replaceable event for order publications. | +| `38384` | `NOSTR_RATING_EVENT_KIND` | Ratings | NIP-33 replaceable event for reputation snapshots. | +| `38385` | `NOSTR_INFO_EVENT_KIND` | Mostro info | NIP-33 replaceable event for operator / node metadata. | +| `38386` | `NOSTR_DISPUTE_EVENT_KIND` | Disputes | NIP-33 replaceable event for dispute publications. | + +> Note: `kind 1` and `kind 13` are *inside* the `kind 1059` GiftWrap transport. They are created and verified by the wrapping/unwrapping code, but they are not emitted or consumed as standalone public relay events by the daemon. + --- ## How It Works @@ -670,8 +690,8 @@ sqlite3 ~/.mostro/mostro.db "SELECT id, dev_fee, dev_fee_paid, dev_fee_payment_h **Query Nostr for Mostro info event**: ```bash # Install nostr tools: cargo install nostreq nostcat -# Fetch Mostro settings (kind 38383) -nostreq --kinds 38383 --limit 1 --authors YOUR_MOSTRO_PUBKEY | nostcat --stream wss://relay.damus.io | jq +# Fetch Mostro settings (kind 38385) +nostreq --kinds 38385 --limit 1 --authors YOUR_MOSTRO_PUBKEY | nostcat --stream wss://relay.damus.io | jq ``` --- @@ -1057,21 +1077,21 @@ Mostro implements a reputation system where users rate their experience with eac 1. **Setup Infrastructure**: Follow [INSTALL.md](INSTALL.md) for production deployment 2. **Configure Settings**: Set fee structure, currencies, limits in `settings.toml` -3. **Announce Your Mostro**: Publish info event (kind 38383) - done automatically on startup +3. **Announce Your Mostro**: Publish info event (kind 38385) - done automatically on startup 4. **Add to Directories**: Submit your Mostro to community listings and websites 5. **Assign Arbiters**: Add trusted npubs as dispute solvers via RPC or admin actions ### Finding Other Mostros Users can discover Mostro operators by: -- Querying Nostr for kind 38383 events (Mostro info) +- Querying Nostr for kind 38385 events (Mostro info) - Checking community-maintained directories (e.g., mostro.network) - Word-of-mouth and local Bitcoin communities **Example Query**: ```bash # Find all Mostro instances on Nostr -nostreq --kinds 38383 | nostcat --stream wss://relay.damus.io | jq +nostreq --kinds 38385 | nostcat --stream wss://relay.damus.io | jq ``` ### Operator Support From c7db272b3c4c298b52d051cd8c86d4c902bfdc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Thu, 11 Jun 2026 21:01:25 +0200 Subject: [PATCH 06/23] =?UTF-8?q?feat(price):=20Phase=201=20=E2=80=94=20Ya?= =?UTF-8?q?dio=20provider=20+=20PriceManager=20wiring=20(#753)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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> + 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) * fix(price): track real contributors, one-shot stale warning, scheduler outage log Addresses the three review findings on #753. 1. Nostr `source` tag now reflects **actual contributors**, not the broader "polled successfully" list. A provider scoped out by `only`/`except` lands in `report.successes` (it did poll OK, so the Phase 2 circuit breaker stays happy) but **not** in the new `report.contributors`, so the kind-30078 `source` tag never names a provider that didn't move the aggregate. Contributors are computed at the manager boundary from the post-scope quote maps; tracking outlier-rejected individual quotes would require pairing every `Quote` with a `ProviderId` through the pure `aggregate_tick`, which is out of scope for Phase 1 and noted as a Phase 2 follow-up. 2. `Err(PriceError::TooStale)` in `get_price` no longer logs on every call. The single `warned_currencies` HashMap (which also collided `Stale` and `SingleSource` flags for the same currency) is split into two independent `HashSet`s — `warned_stale` and `warned_single_source` — so neither flag clobbers the other. The TooStale branch now warns at most once between fresh reads; a fresh `Ok` read clears `warned_stale` so a later regression past the TTL warns again. 3. The scheduler no longer drops the `TickReport`. PriceManager already logs each provider's outcome per tick, so the scheduler only surfaces the outage condition that ops cares about: an `error!` when **every** provider failed (the store is reading last-known-good across the board), and a `warn!` summary on partial outages. Tests (+2, 367 total): - `scoped_out_provider_is_success_but_not_contributor`: a provider whose only quote is filtered by `only` appears in `report.successes` but not in `report.contributors`. - `stale_warning_is_one_shot_then_re_arms_on_fresh_read`: 10 reads against a stale value populate `warned_stale` with one entry; a fresh tick + read clears it so future regressions warn again. cargo fmt, cargo clippy --all-targets --all-features, cargo test all green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(price): track outlier-surviving contributors through aggregate_tick Addresses the Hermes review on #753: the prior fix computed contributors at the manager boundary as "post-scope non-empty quotes", which still claimed a provider as contributor when `combine`'s outlier filter dropped its value. The Nostr `source` tag advertised providers that had no effect on the aggregate. Threads provenance through the pure-function aggregation core instead: - `aggregate_tick` now takes `&[(ProviderId, ProviderQuotes)]`. Direct (PerBtc) quotes and resolved PerBase quotes carry their source id through the pipeline. - `AggregateResult` gains `contributors: Vec` — 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) * 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) * 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) * fix(price): gate + normalize the live Yadio base URL ermeme review on PR #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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- settings.tpl.toml | 40 +- src/bitcoin_price.rs | 431 +------------- src/config/mod.rs | 68 +++ src/config/types.rs | 16 +- src/main.rs | 33 ++ src/price/aggregate.rs | 287 +++++++-- src/price/config.rs | 6 +- src/price/manager.rs | 868 ++++++++++++++++++++++++++++ src/price/mod.rs | 43 +- src/price/provider.rs | 2 +- src/price/providers/mod.rs | 9 + src/price/providers/yadio.rs | 136 +++++ src/price/store.rs | 4 + src/scheduler.rs | 50 +- src/util.rs | 112 +++- tests/fixtures/price/yadio_btc.json | 11 + 16 files changed, 1636 insertions(+), 480 deletions(-) create mode 100644 src/price/manager.rs create mode 100644 src/price/providers/mod.rs create mode 100644 src/price/providers/yadio.rs create mode 100644 tests/fixtures/price/yadio_btc.json diff --git a/settings.tpl.toml b/settings.tpl.toml index 011cffff..b0370fa5 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -54,7 +54,17 @@ publish_relays_interval = 60 pow = 0 # Publish mostro info interval publish_mostro_info_interval = 300 -# Bitcoin price API base URL +# Bitcoin price API base URL. +# DEPRECATED: prefer `[price.providers.yadio].url` in the new multi-source +# `[price]` block below (see docs/PRICE_PROVIDERS.md §10.1). Lifecycle: +# (a) Now OPTIONAL — once you configure a `[price]` block you may delete this +# key. When it is absent AND `[price]` is also absent, the default +# "https://api.yadio.io" is used for the legacy single-source synthesis. +# (b) Still read by the live market-quote path (/convert) until Phase 4. To +# avoid the live and cached paths hitting different Yadio URLs, the live +# path prefers `[price.providers.yadio].url` when a `[price]` block is +# present and only falls back to this key otherwise. +# (c) Fully removed in Phase 5. bitcoin_price_api_url = "https://api.yadio.io" # Fiat currencies accepted for orders - leave empty [] to accept all fiat currencies fiat_currencies_accepted = ['USD', 'EUR', 'ARS', 'CUP'] @@ -88,6 +98,34 @@ port = 50051 # Duration in seconds after which inactive rate-limiter entries are evicted # rate_limiter_stale_duration = 3600 +# Multi-source price providers (see docs/PRICE_PROVIDERS.md). +# Absent section ≡ legacy single-source behaviour synthesised from +# `[mostro].bitcoin_price_api_url` + `exchange_rates_update_interval_seconds` +# + `publish_exchange_rates_to_nostr`. Uncomment the block to take control. +# +# [price] +# # Poll cadence and freshness budget. +# update_interval_seconds = 300 +# max_price_staleness_seconds = 1800 +# # Discard a source whose value deviates more than this % from the median +# # (only applies with >= 3 sources for a currency). +# outlier_threshold_pct = 5.0 +# # Per-provider request timeout and circuit-breaker (cooldown caps at 30 min). +# provider_timeout_seconds = 10 +# provider_failure_threshold = 3 +# provider_failure_cooldown_seconds = 120 +# # Publish the aggregated rates to Nostr (kind 30078). Replaces the legacy +# # `publish_exchange_rates_to_nostr`. +# publish_to_nostr = true +# +# [price.providers.yadio] +# enabled = true +# url = "https://api.yadio.io" +# +# # The keyless backups, El Toque, etc. are wired in Phases 2 and 3 — see +# # docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for the +# # `only` / `except` per-provider currency scoping rules. + # Anti-abuse bond (issue #711). Opt-in, disabled by default. Uncomment to # require a Lightning hold-invoice bond from takers and/or makers. See # docs/ANTI_ABUSE_BOND.md for the full phased rollout. diff --git a/src/bitcoin_price.rs b/src/bitcoin_price.rs index 5460888f..5713af96 100644 --- a/src/bitcoin_price.rs +++ b/src/bitcoin_price.rs @@ -1,423 +1,40 @@ -use crate::config::settings::Settings; -use crate::lnurl::HTTP_CLIENT; -use crate::nip33::new_exchange_rates_event; -use crate::util::{get_keys, get_nostr_client}; -use chrono::Utc; -use mostro_core::prelude::*; -use nostr_sdk::prelude::*; -use once_cell::sync::Lazy; -use serde::Deserialize; -use std::collections::HashMap; -use std::sync::RwLock; -use tracing::{error, info, warn}; - -#[derive(Debug, Deserialize)] -struct YadioResponse { - // Yadio reports `null` for currencies it currently has no rate for - // (observed: `"BGN": null`). A strict `HashMap` makes serde - // reject the *entire* response on the first null, taking every currency - // down with it. Parse leniently as `Option` and drop the bad - // entries in `update_prices`. - #[serde(rename = "BTC")] - btc: HashMap>, -} +//! Legacy `BitcoinPriceManager` shim (spec §9 Phase 1 / §10.1). +//! +//! The real price logic lives in [`crate::price`] from Phase 1 onward. +//! This module survives only as a thin `get_price` delegate so any +//! downstream caller still referring to `BitcoinPriceManager` keeps +//! compiling; the type itself is scheduled for removal in Phase 5 once +//! every consumer reads through `PriceManager` directly. +#![allow(dead_code)] -static BITCOIN_PRICES: Lazy>> = - Lazy::new(|| RwLock::new(HashMap::new())); +use mostro_core::prelude::*; pub struct BitcoinPriceManager; impl BitcoinPriceManager { - pub async fn update_prices() -> Result<(), MostroError> { - let mostro_settings = Settings::get_mostro(); - let api_url = format!("{}/exrates/BTC", mostro_settings.bitcoin_price_api_url); - let response = HTTP_CLIENT - .get(&api_url) - .send() - .await - .map_err(|_| MostroInternalErr(ServiceError::NoAPIResponse))?; - let yadio_response: YadioResponse = response - .json() - .await - .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; - - // Keep only currencies with a usable rate: drop `null` (currencies - // Yadio has no price for) and any non-finite / non-positive value. - let rates_clone: HashMap = yadio_response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - - // A response with zero usable rates (everything null/invalid, or an - // empty BTC object) must NOT overwrite the cache — that would turn a - // transient provider hiccup into total price unavailability for every - // currency. Keep the last known prices and try again next tick. - if rates_clone.is_empty() { - warn!("Yadio returned no usable BTC rates; keeping previously cached prices"); - return Ok(()); - } - - info!( - "Bitcoin prices updated. Got BTC price in {} fiat currencies", - rates_clone.len() - ); - - { - let mut prices_write = BITCOIN_PRICES - .write() - .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - *prices_write = rates_clone.clone(); - } // Lock is dropped here - - // Publish rates to Nostr if enabled (after releasing the lock) - if mostro_settings.publish_exchange_rates_to_nostr { - if let Err(e) = Self::publish_rates_to_nostr(&rates_clone).await { - error!("Failed to publish exchange rates to Nostr: {}", e); - // Don't fail the entire update if Nostr publishing fails - } - } - - Ok(()) - } - - /// Publishes exchange rates to Nostr as a NIP-33 addressable event (kind 30078) - async fn publish_rates_to_nostr(rates: &HashMap) -> Result<(), MostroError> { - let keys = get_keys().map_err(|e| { - error!("Failed to get Mostro keys: {}", e); - MostroInternalErr(ServiceError::IOError(e.to_string())) - })?; - - // Publish in Yadio's exact format: {"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}} - // This matches their API response structure - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), rates.clone()); - let formatted_rates = wrapper; - - let content = serde_json::to_string(&formatted_rates) - .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; - - let timestamp = Utc::now().timestamp(); - - // Expiration should be at least 2x the update interval to allow for delays - // Cap at 1 hour to prevent stale data - // Note: We read settings here (instead of passing from scheduler) to ensure - // expiration stays aligned with interval if config is reloaded at runtime - let mostro_settings = Settings::get_mostro(); - let update_interval = mostro_settings.exchange_rates_update_interval_seconds; - let expiration_seconds = std::cmp::min(update_interval * 2, 3600); - let expiration = timestamp + expiration_seconds as i64; - - let tags = Tags::from_list(vec![ - Tag::custom( - TagKind::Custom("published_at".into()), - vec![timestamp.to_string()], - ), - Tag::custom(TagKind::Custom("source".into()), vec!["yadio".to_string()]), - Tag::expiration(Timestamp::from(expiration as u64)), - ]); - - let event = new_exchange_rates_event(&keys, &content, tags).map_err(|e| { - error!("Failed to create exchange rates event: {}", e); - MostroInternalErr(ServiceError::MessageSerializationError) - })?; - - let client = get_nostr_client().map_err(|e| { - error!("Failed to get Nostr client: {}", e); - e - })?; - - // Publish with timeout to avoid blocking the scheduler - // Best-effort: log errors but don't fail the update job - let timeout_duration = std::time::Duration::from_secs(30); - match tokio::time::timeout(timeout_duration, client.send_event(&event)).await { - Ok(Ok(output)) => { - info!( - "Exchange rates published to Nostr ({} currencies). Output: {:?}", - rates.len(), - output - ); - } - Ok(Err(e)) => { - error!("Failed to send exchange rates event to relays: {}", e); - } - Err(_) => { - error!("Timeout publishing exchange rates to Nostr (30s exceeded)"); - } - } - - // Always return Ok - publishing is best-effort - Ok(()) - } - + /// Delegates to [`crate::price::get_bitcoin_price`]. Behaviour is + /// identical to the legacy implementation: an uppercase ISO-4217 code + /// returns the per-BTC value if the global [`crate::price::PriceManager`] + /// has it, otherwise `Err(NoAPIResponse)` (matching "no data yet" in + /// the pre-Phase-1 world). pub fn get_price(currency: &str) -> Result { - let prices_read: std::sync::RwLockReadGuard<'_, HashMap> = BITCOIN_PRICES - .read() - .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - prices_read - .get(currency) - .cloned() - .ok_or(MostroInternalErr(ServiceError::NoAPIResponse)) + crate::price::get_bitcoin_price(currency) } } #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; - - #[test] - fn test_rates_structure() { - // Test that Yadio rates are wrapped correctly - let mut input_rates = HashMap::new(); - input_rates.insert("USD".to_string(), 50000.0); - input_rates.insert("EUR".to_string(), 45000.0); - - // Wrap in Yadio format: {"BTC": {...}} - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), input_rates.clone()); - - assert_eq!(wrapper.len(), 1); - assert!(wrapper.contains_key("BTC")); - assert_eq!(wrapper.get("BTC").unwrap().get("USD"), Some(&50000.0)); - assert_eq!(wrapper.get("BTC").unwrap().get("EUR"), Some(&45000.0)); - } #[test] - fn test_rates_json_serialization() { - // Test that rates can be serialized to Yadio format - // Use only fiat currencies (Yadio includes BTC in the wrapper, not in the rates map) - let mut input_rates = HashMap::new(); - input_rates.insert("USD".to_string(), 50000.0); - input_rates.insert("EUR".to_string(), 45000.0); - - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), input_rates); - - let json = serde_json::to_string(&wrapper).unwrap(); - assert!(json.contains("\"BTC\"")); - assert!(json.contains("\"USD\"")); - assert!(json.contains("50000")); - assert!(json.contains("\"EUR\"")); - assert!(json.contains("45000")); - // Ensure we don't have nested BTC key (would be invalid) - assert!(!json.contains("\"BTC\":1")); - } - - #[test] - fn test_yadio_response_deserialization() { - // Test that we can deserialize the expected API response format - let json_response = r#" - { - "BTC": { - "USD": 50000.0, - "EUR": 45000.0, - "GBP": 40000.0 - } - } - "#; - - let result: Result = serde_json::from_str(json_response); - assert!(result.is_ok()); - - let response = result.unwrap(); - assert_eq!(response.btc.get("USD"), Some(&Some(50000.0))); - assert_eq!(response.btc.get("EUR"), Some(&Some(45000.0))); - assert_eq!(response.btc.get("GBP"), Some(&Some(40000.0))); - assert_eq!(response.btc.len(), 3); - } - - #[test] - fn test_yadio_response_with_null_rate_is_parsed_and_filtered() { - // Regression: Yadio now returns `null` for currencies it has no rate - // for (e.g. "BGN": null). The lenient `Option` parse must accept - // the whole payload, and the same filter `update_prices` applies must - // drop the null while keeping the good currencies. - let json_response = r#" - { - "BTC": { "USD": 75899.55, "EUR": 65393.99, "BGN": null }, - "base": "BTC", - "timestamp": 1779480604069 - } - "#; - - let response: YadioResponse = serde_json::from_str(json_response).expect("must parse"); - assert_eq!(response.btc.get("BGN"), Some(&None)); - - let rates: HashMap = response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - - assert_eq!(rates.len(), 2, "null BGN must be dropped"); - assert_eq!(rates.get("USD"), Some(&75899.55)); - assert_eq!(rates.get("EUR"), Some(&65393.99)); - assert!(!rates.contains_key("BGN")); - } - - #[test] - fn test_yadio_response_all_null_filters_to_empty() { - // When every rate is null/invalid the payload still parses, but the - // filter yields an empty map — the condition under which - // `update_prices` preserves the previously cached prices instead of - // overwriting them with nothing. - let json_response = r#"{ "BTC": { "BGN": null, "ZZZ": null }, "base": "BTC" }"#; - let response: YadioResponse = serde_json::from_str(json_response).expect("must parse"); - let rates: HashMap = response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - assert!( - rates.is_empty(), - "all-null response must filter to an empty rate set" - ); - } - - #[test] - fn test_yadio_response_invalid_json() { - // Test deserialization with invalid JSON - let invalid_json = r#"{"invalid": "structure"}"#; - - let result: Result = serde_json::from_str(invalid_json); - assert!(result.is_err()); - } - - #[test] - fn test_yadio_response_empty_btc() { - // Test deserialization with empty BTC object - let json_response = r#"{"BTC": {}}"#; - - let result: Result = serde_json::from_str(json_response); - assert!(result.is_ok()); - - let response = result.unwrap(); - assert_eq!(response.btc.len(), 0); - } - - #[test] - fn test_currency_code_validation() { - // Test various currency code formats - let valid_currencies = vec!["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF"]; - let invalid_currencies = vec!["", "us", "USDD", "123", "usd"]; - - // Test valid currencies (should not panic) - for currency in valid_currencies { - let _result = BitcoinPriceManager::get_price(currency); - // No assertion needed; this ensures no panic for valid input - } - - // Test invalid currencies (should not panic) - for currency in invalid_currencies { - let _result = BitcoinPriceManager::get_price(currency); - // No assertion needed; this ensures no panic for invalid input - } - } - - #[test] - fn test_bitcoin_price_manager_api_url() { - // Test that API URL configuration is properly handled - let expected_base = "https://api.yadio.io"; - assert!(expected_base.starts_with("https://")); - assert!(expected_base.contains("yadio.io")); - } - - mod error_handling_tests { - use super::*; - - #[test] - fn test_json_parsing_errors() { - // Test various JSON parsing error scenarios - let invalid_responses = vec![ - "", // Empty response - "{", // Incomplete JSON - "null", // Null response - "[]", // Array instead of object - r#"{"BTC": null}"#, // Null BTC field - r#"{"BTC": []}"#, // Array instead of object for BTC - r#"{"BTC": {"USD": "invalid"}}"#, // Invalid number format - ]; - - for invalid_json in invalid_responses { - let result: Result = serde_json::from_str(invalid_json); - // All should fail to deserialize - assert!(result.is_err()); - } - } - } - - mod price_cache_tests { - use super::*; - - #[test] - fn test_price_cache_operations() { - // Test the logical flow of price caching - - // Test that we can conceptually store and retrieve prices - let test_currencies = HashMap::from([ - ("USD".to_string(), 50000.0), - ("EUR".to_string(), 45000.0), - ("GBP".to_string(), 40000.0), - ]); - - // Verify our test data is valid - assert_eq!(test_currencies.len(), 3); - assert!(test_currencies.contains_key("USD")); - assert_eq!(test_currencies.get("USD"), Some(&50000.0)); - - // Test currency code normalization (uppercase) - for currency in test_currencies.keys() { - assert_eq!(currency, ¤cy.to_uppercase()); - assert!(currency.len() == 3); // Standard currency code length - } - } - - #[test] - fn test_concurrent_access_safety() { - // Test that the static BITCOIN_PRICES can handle concurrent access - // This tests the thread safety of our RwLock usage - - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - use std::thread; - - let success = Arc::new(AtomicBool::new(true)); - let mut handles = vec![]; - - // Spawn multiple threads trying to read prices - for _ in 0..5 { - let success_clone = Arc::clone(&success); - let handle = thread::spawn(move || { - for _ in 0..10 { - match BitcoinPriceManager::get_price("USD") { - Ok(_) | Err(_) => { - // Both outcomes are acceptable for this test - // We're just testing that it doesn't panic - } - } - } - success_clone.store(true, Ordering::Relaxed); - }); - handles.push(handle); - } - - // Wait for all threads to complete - for handle in handles { - handle.join().expect("Thread should not panic"); - } - - // All threads should have completed successfully - assert!(success.load(Ordering::Relaxed)); - } + fn unset_manager_returns_no_api_response() { + // Unit tests never install the global PriceManager. The shim must + // surface the same error the legacy `BITCOIN_PRICES.get` empty-map + // path used to surface, so callers behave identically. + let err = BitcoinPriceManager::get_price("USD").unwrap_err(); + assert!(matches!( + err, + MostroError::MostroInternalErr(ServiceError::NoAPIResponse) + )); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index a410eb15..6d66c92d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -233,4 +233,72 @@ mod tests { ); assert_eq!(mostro_settings.mostro.max_orders_per_response, 10); } + + // Same as MOSTRO_SETTINGS but with `bitcoin_price_api_url` omitted — the + // shape an operator who has migrated to a `[price]` block and deleted the + // deprecated key would have (spec §10.1). + const MOSTRO_SETTINGS_NO_PRICE_URL: &str = r#"[mostro] + fee = 0 + max_routing_fee = 0.002 + max_order_amount = 1000000 + min_payment_amount = 100 + expiration_hours = 24 + max_expiration_days = 15 + expiration_seconds = 900 + user_rates_sent_interval_seconds = 3600 + publish_relays_interval = 60 + pow = 0 + publish_mostro_info_interval = 300 + fiat_currencies_accepted = ['USD', 'EUR', 'ARS', 'CUP'] + max_orders_per_response = 10 + dev_fee_percentage = 0.30"#; + + #[test] + fn test_mostro_settings_without_bitcoin_price_api_url_defaults() { + // A settings.toml that omits the deprecated key must still + // deserialize (it is `#[serde(default)]`), falling back to the same + // URL the `Default` impl uses. + let parsed: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS_NO_PRICE_URL).expect("must deserialize without the key"); + assert_eq!( + parsed.mostro.bitcoin_price_api_url, "https://api.yadio.io", + "omitted legacy key must fall back to the default URL" + ); + } + + #[test] + fn test_legacy_synthesis_same_whether_key_present_or_defaulted() { + // Legacy synthesis (`[price]` absent) must produce the same + // single-yadio config whether the deprecated key was present at its + // default value or omitted entirely (spec §10.1 byte-for-byte + // compatibility). + let omitted: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS_NO_PRICE_URL).expect("deserialize (omitted)"); + let present: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS).expect("deserialize (present)"); + + let synth = |m: &MostroSettings| { + crate::price::synthesise_legacy_price_settings( + &m.bitcoin_price_api_url, + m.exchange_rates_update_interval_seconds, + m.publish_exchange_rates_to_nostr, + ) + }; + let from_omitted = synth(&omitted.mostro); + let from_present = synth(&present.mostro); + + // Exactly one provider (yadio) in both, with the default URL. + for cfg in [&from_omitted, &from_present] { + assert_eq!(cfg.providers.len(), 1); + let yadio = cfg.providers.get("yadio").expect("yadio provider present"); + assert!(yadio.enabled); + assert_eq!(yadio.url, "https://api.yadio.io"); + } + // And the synthesised cadence / publish flag match across the two. + assert_eq!( + from_omitted.update_interval_seconds, + from_present.update_interval_seconds + ); + assert_eq!(from_omitted.publish_to_nostr, from_present.publish_to_nostr); + } } diff --git a/src/config/types.rs b/src/config/types.rs index efc8342b..85615283 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -340,7 +340,15 @@ pub struct MostroSettings { pub pow: u8, /// Publish mostro info interval pub publish_mostro_info_interval: u32, - /// Bitcoin price API base URL + /// Bitcoin price API base URL. + /// + /// DEPRECATED (spec §10.1): superseded by `[price.providers.yadio].url`. + /// `#[serde(default)]` so a `settings.toml` that has migrated to a + /// `[price]` block may omit this key entirely without failing + /// deserialization. Still read by the live `/convert` path + /// (`src/util.rs`) and by `install_price_manager()` legacy synthesis when + /// `[price]` is absent, so the field itself stays until Phase 4/5. + #[serde(default = "default_bitcoin_price_api_url")] pub bitcoin_price_api_url: String, /// Fiat currencies accepted for orders (empty list accepts all) pub fiat_currencies_accepted: Vec, @@ -365,6 +373,12 @@ pub struct MostroSettings { pub exchange_rates_update_interval_seconds: u64, } +fn default_bitcoin_price_api_url() -> String { + // Matches the `Default` impl below so an omitted legacy key and an + // explicit one behave identically for legacy synthesis (spec §10.1). + "https://api.yadio.io".to_string() +} + fn default_publish_exchange_rates() -> bool { true // Enable by default for censorship resistance } diff --git a/src/main.rs b/src/main.rs index cd343d35..c823538f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,11 @@ async fn main() -> Result<()> { // Init MOSTRO_SETTINGS oncelock with all settings variables from TOML file settings_init()?; + // Build and install the multi-source price manager (spec §9 Phase 1). + // Done immediately after settings load so every later subsystem + // (scheduler, util::get_bitcoin_price, RPC) can read prices through it. + install_price_manager()?; + // Connect to database if DB_POOL.set(db::connect().await?).is_err() { tracing::error!("No connection to database - closing Mostro!"); @@ -186,6 +191,34 @@ async fn main() -> Result<()> { run(ctx, &mut ln_client).await } +/// Build the multi-source [`crate::price::PriceManager`] from settings and +/// install it as the process-wide global. When `[price]` is absent in the +/// settings file we synthesise it from the legacy `[mostro]` keys +/// (`bitcoin_price_api_url`, `exchange_rates_update_interval_seconds`, +/// `publish_exchange_rates_to_nostr`) so existing `settings.toml` files keep +/// working byte-for-byte (spec §10.1). +fn install_price_manager() -> std::result::Result<(), Box> { + use crate::price::{synthesise_legacy_price_settings, PriceManager}; + + let mostro_settings = Settings::get_mostro(); + let price_settings = match Settings::get_price() { + Some(p) => p.clone(), + None => synthesise_legacy_price_settings( + &mostro_settings.bitcoin_price_api_url, + mostro_settings.exchange_rates_update_interval_seconds, + mostro_settings.publish_exchange_rates_to_nostr, + ), + }; + + let manager = PriceManager::from_settings(price_settings) + .map_err(|e| -> Box { format!("price: {e}").into() })?; + manager + .install_global() + .map_err(|e| -> Box { format!("price: {e}").into() })?; + tracing::info!("PriceManager installed"); + Ok(()) +} + #[cfg(test)] mod tests { use mostro_core::message::Message; diff --git a/src/price/aggregate.rs b/src/price/aggregate.rs index fb59d125..89809c79 100644 --- a/src/price/aggregate.rs +++ b/src/price/aggregate.rs @@ -4,14 +4,21 @@ use std::collections::HashMap; -use super::provider::{ProviderQuotes, Quote}; +use super::provider::{ProviderId, ProviderQuotes, Quote}; -/// One currency's aggregated result for a tick: the price and how many -/// sources contributed it (before outlier removal). -#[derive(Debug, Clone, Copy, PartialEq)] +/// One currency's aggregated result for a tick. +/// +/// `sources` is the count of clean, candidate values that fed into +/// [`combine`] (the spec §6.4 "source_count" the store also stores). +/// `contributors` is the sorted, deduplicated list of providers whose +/// value actually **survived** [`combine`]'s outlier filter — what the +/// Nostr `source` tag really means. The two can differ: with three +/// providers and one outlier the count is 3 but contributors is 2. +#[derive(Debug, Clone, PartialEq)] pub struct AggregateResult { pub value: f64, pub sources: u8, + pub contributors: Vec, } /// Combine a currency's candidate per-BTC prices into one figure (spec §6.2). @@ -95,72 +102,155 @@ pub fn resolve_per_base( /// Run steps 1–3 of the §5.3 pipeline over one tick's provider results. /// -/// `provider_results` holds the `ProviderQuotes` of each provider that -/// succeeded this tick (failed providers contribute nothing — they are -/// simply absent). Currency codes are upper-cased so providers that -/// disagree on casing still combine (spec §6.6). +/// `provider_results` holds the `(ProviderId, ProviderQuotes)` pairs of +/// every provider that succeeded this tick (failed providers contribute +/// nothing — they are simply absent). Currency codes are upper-cased so +/// providers that disagree on casing still combine (spec §6.6). /// -/// 1. Direct (`PerBtc`) quotes are grouped per currency. +/// 1. Direct (`PerBtc`) quotes are grouped per currency, **paired with +/// their provider id**. /// 2. Per-currency **anchors** are the [`combine`]d direct quotes; fiat-cross -/// (`PerBase`) quotes are resolved against those anchors. +/// (`PerBase`) quotes are resolved against those anchors and attributed +/// to the fiat-cross provider (the anchor's own contributors are an +/// intermediate, not a contributor to the resolved currency). /// 3. Each currency's final value is the [`combine`] of its direct **and** -/// resolved candidates. -/// -/// Returns the per-currency [`AggregateResult`] (value + contributing -/// source count). The caller stamps these into the store with the tick -/// timestamp. +/// resolved candidates. `contributors` lists the providers whose value +/// survived the outlier filter ([`kept_contributors`]) — what the Nostr +/// `source` tag actually represents (spec §9 Phase 1). pub fn aggregate_tick( - provider_results: &[ProviderQuotes], + provider_results: &[(ProviderId, ProviderQuotes)], outlier_pct: f64, ) -> HashMap { - let mut direct: HashMap> = HashMap::new(); - let mut per_base: Vec<(String, String, f64)> = Vec::new(); + // Per-currency direct (PerBtc) quotes paired with their source id. + let mut direct: HashMap> = HashMap::new(); + // PerBase quotes paired with their source id; resolved in step 2. + let mut per_base: Vec<(ProviderId, String, String, f64)> = Vec::new(); - for quotes in provider_results { + for (id, quotes) in provider_results { for (currency, quote) in quotes { let currency = currency.to_uppercase(); match quote { - Quote::PerBtc(v) => direct.entry(currency).or_default().push(*v), + Quote::PerBtc(v) => direct.entry(currency).or_default().push((*id, *v)), Quote::PerBase { base, value } => { - per_base.push((currency, base.to_uppercase(), *value)) + per_base.push((*id, currency, base.to_uppercase(), *value)) } } } } - // Step 2: anchors = aggregated direct quotes, then resolve cross quotes. + // Step 2: anchors = aggregated direct quotes (values only), then + // resolve cross quotes — attributing each resolved candidate to the + // fiat-cross provider that emitted it. Anchor contributors are *not* + // propagated into resolved-currency contributors: they are an + // intermediate of the cross math, not an upstream of the cross + // currency. let mut anchors: HashMap = HashMap::new(); - for (currency, candidates) in &direct { - if let Some(v) = combine(candidates, outlier_pct) { + for (currency, pairs) in &direct { + let values: Vec = pairs.iter().map(|(_, v)| *v).collect(); + if let Some(v) = combine(&values, outlier_pct) { anchors.insert(currency.clone(), v); } } - let resolved = resolve_per_base(&per_base, &anchors); + let mut resolved: HashMap> = HashMap::new(); + for (id, currency, base, value) in &per_base { + if let Some(anchor) = anchors.get(base) { + let candidate = value * anchor; + if candidate.is_finite() && candidate > 0.0 { + resolved + .entry(currency.clone()) + .or_default() + .push((*id, candidate)); + } + } + } - // Step 3: combine direct + resolved candidates per currency. + // Step 3: combine direct + resolved candidates per currency, and + // attach the actual surviving contributors. let mut out: HashMap = HashMap::new(); let currencies: std::collections::HashSet<&String> = direct.keys().chain(resolved.keys()).collect(); for currency in currencies { - let mut candidates: Vec = Vec::new(); + let mut pairs: Vec<(ProviderId, f64)> = Vec::new(); if let Some(d) = direct.get(currency) { - candidates.extend_from_slice(d); + pairs.extend_from_slice(d); } if let Some(r) = resolved.get(currency) { - candidates.extend_from_slice(r); + pairs.extend_from_slice(r); } - let sources = candidates - .iter() - .filter(|x| x.is_finite() && **x > 0.0) - .count() - .min(u8::MAX as usize) as u8; + let candidates: Vec = pairs.iter().map(|(_, v)| *v).collect(); if let Some(value) = combine(&candidates, outlier_pct) { - out.insert(currency.clone(), AggregateResult { value, sources }); + let contributors = kept_contributors(&pairs, outlier_pct); + let sources = candidates + .iter() + .filter(|x| x.is_finite() && **x > 0.0) + .count() + .min(u8::MAX as usize) as u8; + out.insert( + currency.clone(), + AggregateResult { + value, + sources, + contributors, + }, + ); } } out } +/// Return the provider ids whose value actually survives [`combine`]'s +/// "kept" predicate for one currency's candidate pairs — i.e. those +/// providers the Nostr `source` tag should advertise (spec §9 Phase 1 +/// "contributing-source list"). The predicate mirrors [`combine`]: +/// +/// - clean: drop non-finite and non-positive, +/// - `n <= 2`: every clean provider contributes, +/// - `n >= 3`: only providers whose value lies within +/// `outlier_pct` percent of the median contribute, +/// - `n >= 3` with no value inside the outlier band (the +/// even-length bimodal fallback in [`combine`]): every clean provider +/// contributes, since no single source is demonstrably the outlier and +/// `combine` falls back to the median itself. +/// +/// Multiple paths from the same provider for the same currency (a direct +/// + a fiat-cross resolution, for example) are deduplicated. +fn kept_contributors(pairs: &[(ProviderId, f64)], outlier_pct: f64) -> Vec { + let clean: Vec<(ProviderId, f64)> = pairs + .iter() + .copied() + .filter(|(_, x)| x.is_finite() && *x > 0.0) + .collect(); + if clean.is_empty() { + return Vec::new(); + } + if clean.len() <= 2 { + return dedup_sort(clean.into_iter().map(|(id, _)| id).collect()); + } + let mut values: Vec = clean.iter().map(|(_, v)| *v).collect(); + values.sort_by(|a, b| a.partial_cmp(b).expect("finite values sort")); + let m = median_sorted(&values); + let tol = m * (outlier_pct / 100.0); + let kept: Vec = clean + .iter() + .copied() + .filter(|(_, x)| (x - m).abs() <= tol) + .map(|(id, _)| id) + .collect(); + if kept.is_empty() { + // Bimodal fallback: combine returns the median (a synthetic + // midpoint matching no value). No single provider is the outlier + // here, so every clean provider stays a contributor. + return dedup_sort(clean.into_iter().map(|(id, _)| id).collect()); + } + dedup_sort(kept) +} + +fn dedup_sort(mut ids: Vec) -> Vec { + ids.sort(); + ids.dedup(); + ids +} + fn mean(xs: &[f64]) -> f64 { xs.iter().sum::() / xs.len() as f64 } @@ -269,15 +359,33 @@ mod tests { }, ); - let out = aggregate_tick(&[yadio, coingecko, eltoque], PCT); + let out = aggregate_tick( + &[ + (ProviderId::Yadio, yadio), + (ProviderId::CoinGecko, coingecko), + (ProviderId::ElToque, eltoque), + ], + PCT, + ); approx(out["USD"].value, 50_000.0); assert_eq!(out["USD"].sources, 2); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CoinGecko], + "USD: both direct quoters survived" + ); approx(out["EUR"].value, 45_000.0); assert_eq!(out["EUR"].sources, 2); - // CUP = combine(Yadio 20M [direct], El Toque 400×50_000 = 20M [resolved]). approx(out["CUP"].value, 20_000_000.0); assert_eq!(out["CUP"].sources, 2); + // CUP contributors: Yadio (direct) + El Toque (fiat-cross + // resolved via USD anchor). The USD anchor's own contributors + // (Yadio, CoinGecko) are NOT propagated — they're an intermediate. + assert_eq!( + out["CUP"].contributors, + vec![ProviderId::Yadio, ProviderId::ElToque] + ); } #[test] @@ -286,9 +394,10 @@ mod tests { // was never added). USD has a single source. let mut yadio = ProviderQuotes::new(); yadio.insert("USD".into(), Quote::PerBtc(50_000.0)); - let out = aggregate_tick(&[yadio], PCT); + let out = aggregate_tick(&[(ProviderId::Yadio, yadio)], PCT); approx(out["USD"].value, 50_000.0); assert_eq!(out["USD"].sources, 1); + assert_eq!(out["USD"].contributors, vec![ProviderId::Yadio]); } #[test] @@ -298,10 +407,15 @@ mod tests { a.insert("usd".into(), Quote::PerBtc(50_000.0)); let mut b = ProviderQuotes::new(); b.insert("USD".into(), Quote::PerBtc(50_200.0)); - let out = aggregate_tick(&[a, b], PCT); + let out = aggregate_tick(&[(ProviderId::CurrencyApi, a), (ProviderId::Yadio, b)], PCT); assert_eq!(out.len(), 1, "lowercase and uppercase must merge"); approx(out["USD"].value, 50_100.0); assert_eq!(out["USD"].sources, 2); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CurrencyApi], + "n=2: both contributors keep their seat" + ); } #[test] @@ -315,7 +429,100 @@ mod tests { value: 400.0, }, ); - let out = aggregate_tick(&[eltoque], PCT); + let out = aggregate_tick(&[(ProviderId::ElToque, eltoque)], PCT); assert!(out.is_empty(), "no USD anchor → CUP cannot resolve"); } + + #[test] + fn aggregate_tick_outlier_drops_provider_from_contributors() { + // The motivating case for the review: three providers, one is a + // wild outlier. `combine` drops it from the value, so the Nostr + // `source` tag must NOT advertise it. With median = 50_100 and + // tol = 5% = 2505, the outlier 75_000 sits way outside the band. + let mut yadio = ProviderQuotes::new(); + yadio.insert("USD".into(), Quote::PerBtc(50_000.0)); + let mut coingecko = ProviderQuotes::new(); + coingecko.insert("USD".into(), Quote::PerBtc(50_200.0)); + let mut blockchain = ProviderQuotes::new(); + blockchain.insert("USD".into(), Quote::PerBtc(75_000.0)); + + let out = aggregate_tick( + &[ + (ProviderId::Yadio, yadio), + (ProviderId::CoinGecko, coingecko), + (ProviderId::Blockchain, blockchain), + ], + PCT, + ); + + // Value: mean of the in-band pair (Yadio 50_000 + CoinGecko 50_200). + approx(out["USD"].value, 50_100.0); + // `sources` still counts all three clean candidates (pre-outlier). + assert_eq!(out["USD"].sources, 3); + // `contributors` is the in-band set — Blockchain dropped. + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CoinGecko], + "outlier provider must not be advertised in the source tag" + ); + } + + #[test] + fn aggregate_tick_bimodal_fallback_keeps_all_clean_contributors() { + // Even-length bimodal: combine falls back to the synthetic median. + // No single provider is demonstrably the outlier, so every clean + // provider stays a contributor — matching the comment in + // `kept_contributors`. + let mk = |v: f64| { + let mut q = ProviderQuotes::new(); + q.insert("USD".into(), Quote::PerBtc(v)); + q + }; + let out = aggregate_tick( + &[ + (ProviderId::Yadio, mk(1.0)), + (ProviderId::CoinGecko, mk(2.0)), + (ProviderId::CurrencyApi, mk(100.0)), + (ProviderId::Blockchain, mk(101.0)), + ], + PCT, + ); + approx(out["USD"].value, 51.0); // (2+100)/2 + // All four clean providers survive the bimodal fallback. + // `kept_contributors` -> `dedup_sort` sorts by the derived `Ord` + // on `ProviderId`, which follows enum-variant declaration order + // (Yadio, CoinGecko, CurrencyApi, Blockchain, ElToque). Pin the + // exact list so a future refactor that perturbs ordering — or + // accidentally drops a contributor — is caught by this test + // rather than slipping past a loose `.len()` check. + assert_eq!( + out["USD"].contributors, + vec![ + ProviderId::Yadio, + ProviderId::CoinGecko, + ProviderId::CurrencyApi, + ProviderId::Blockchain, + ] + ); + } + + #[test] + fn aggregate_tick_non_finite_value_drops_provider_from_contributors() { + // A provider returning `0` or `NaN` for the only currency it + // reports must not be claimed as a contributor. + let mut bad = ProviderQuotes::new(); + bad.insert("USD".into(), Quote::PerBtc(f64::NAN)); + let mut good = ProviderQuotes::new(); + good.insert("USD".into(), Quote::PerBtc(50_000.0)); + let out = aggregate_tick( + &[(ProviderId::Yadio, bad), (ProviderId::CoinGecko, good)], + PCT, + ); + approx(out["USD"].value, 50_000.0); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::CoinGecko], + "NaN must drop the provider from contributors, not silently survive" + ); + } } diff --git a/src/price/config.rs b/src/price/config.rs index df37dbe1..6e77e76e 100644 --- a/src/price/config.rs +++ b/src/price/config.rs @@ -74,12 +74,14 @@ impl ProviderConfig { pub fn validate(&self, id: &str) -> Result<(), String> { if self.only.is_some() && self.except.is_some() { return Err(format!( - "price provider '{id}': `only` and `except` are mutually exclusive" + "price provider '{id}': `only` and `except` are mutually exclusive \ + (see docs/PRICE_PROVIDERS.md §7)" )); } if self.enabled && self.url.trim().is_empty() { return Err(format!( - "price provider '{id}': enabled provider must have a non-empty `url`" + "price provider '{id}': enabled provider must have a non-empty `url` \ + (see docs/PRICE_PROVIDERS.md §7)" )); } Ok(()) diff --git a/src/price/manager.rs b/src/price/manager.rs new file mode 100644 index 00000000..01215456 --- /dev/null +++ b/src/price/manager.rs @@ -0,0 +1,868 @@ +//! [`PriceManager`]: the registry + scheduler tick + read surface +//! (spec §5.3, §6.4). +//! +//! `PriceManager` owns one [`Box`] per enabled provider +//! plus the aggregated-price [`PriceStore`]. The scheduler calls +//! [`PriceManager::update_all`] every `update_interval_seconds` to poll the +//! providers, aggregate, and write the store; consumers (`get_bitcoin_price`, +//! `BitcoinPriceManager::get_price`) read through [`PriceManager::get_price`]. +//! +//! ## Phase 1 invariants (spec §9 Phase 1) +//! - The registry is built from `[price]`; only Yadio is wired here, the +//! keyless backups land in Phase 2. +//! - Staleness is **logged, not enforced**: a value older than one +//! `update_interval` emits a `warn!` but still returns to the caller, so +//! Phase 1 never refuses an order that would have priced today. +//! Enforcement turns on in Phase 4. +//! - Per-provider failures are isolated: a failed poll contributes nothing +//! this tick and the store's last-known-good value is preserved (spec +//! §6.4). The full circuit breaker integration lands in Phase 2. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock, RwLock}; +use std::time::Duration; + +use chrono::Utc; +use mostro_core::error::{MostroError, ServiceError}; +use nostr_sdk::prelude::*; +use tracing::{error, info, warn}; + +use super::aggregate::{aggregate_tick, AggregateResult}; +use super::config::{PriceSettings, ProviderConfig}; +use super::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes}; +use super::providers::yadio::YadioProvider; +use super::store::{PriceError, PriceStore}; + +/// Process-wide singleton. Initialized once in `main` after settings load, +/// then read by the scheduler (`update_all`) and consumers (`get_price`). +/// Modelled on `MOSTRO_CONFIG`: `OnceLock` so initialization is panic-free +/// and tests that never call `init_global` see `None`. +static PRICE_MANAGER: OnceLock = OnceLock::new(); + +/// One enabled provider plus its registry metadata. Health tracking goes +/// here in Phase 2 — Phase 1 only needs the box. +struct EnabledProvider { + id: ProviderId, + provider: Box, +} + +/// Outer `Result` is from [`tokio::time::timeout`] (Elapsed = timed out), +/// inner is the adapter's `fetch` outcome. +type TimeoutResult = Result, tokio::time::error::Elapsed>; + +/// Runtime state of the multi-source price module. +pub struct PriceManager { + providers: Vec, + store: Arc, + settings: PriceSettings, + http: reqwest::Client, + /// One-shot guards for the two transient log conditions (spec §10.4 + /// asks for transitions, not per-poll spam). Kept as two independent + /// sets so a `Stale` flag never clobbers a `SingleSource` flag (and + /// vice versa) for the same currency — both can hold simultaneously. + warned_stale: RwLock>, + warned_single_source: RwLock>, +} + +impl PriceManager { + /// Build the manager from a `[price]` settings block. + /// + /// Validation runs first so an enabled provider with a missing required + /// secret or an empty `url` fails fast at startup rather than silently + /// returning no quotes (spec §7). Disabled providers are skipped; an + /// unknown id is logged but ignored, so adding a provider in a newer + /// release is forward-compatible with an older `mostrod` (the unknown + /// adapter is simply absent until the binary catches up). + pub fn from_settings(settings: PriceSettings) -> Result { + settings.validate()?; + + let mut providers: Vec = Vec::new(); + for (id_str, cfg) in &settings.providers { + if !cfg.enabled { + continue; + } + match id_str.parse::() { + Ok(id) => { + let provider = build_provider(id, cfg)?; + providers.push(EnabledProvider { id, provider }); + } + Err(_) => { + warn!( + "price: unknown provider id `{id_str}` — ignoring (binary is older than the config?)" + ); + } + } + } + + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(settings.provider_timeout_seconds)) + .user_agent(concat!("mostro/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("price: building HTTP client: {e}"))?; + + Ok(Self { + providers, + store: Arc::new(PriceStore::new()), + settings, + http, + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + }) + } + + /// Install the global manager. Panic-free: subsequent calls return + /// `Err(AlreadyInstalled)` so `main` can detect (and the test suite + /// never collides with) double-initialization. + pub fn install_global(self) -> Result<(), InstallError> { + PRICE_MANAGER + .set(self) + .map_err(|_| InstallError::AlreadyInstalled) + } + + /// Borrow the global manager, if installed. `None` in unit tests that + /// don't bring up the full configuration — every consumer treats that + /// case as "no price available" rather than panicking. + pub fn global() -> Option<&'static PriceManager> { + PRICE_MANAGER.get() + } + + /// Read-only view of the active settings (used by the scheduler to size + /// its sleep and by tests). + pub fn settings(&self) -> &PriceSettings { + &self.settings + } + + /// One scheduler tick: poll all enabled providers concurrently with a + /// per-provider timeout, aggregate, and write the store + /// (spec §5.3 steps 1–3). A failed/timed-out provider contributes + /// nothing — the store's prior values for its currencies survive as + /// last-known-good (spec §6.4). + /// + /// Returns the per-provider outcome so the scheduler / Phase 2 circuit + /// breaker can act on it. Phase 1 only logs it. + pub async fn update_all(&self) -> TickReport { + let mut report = TickReport::default(); + if self.providers.is_empty() { + warn!("price: no providers enabled — skipping tick"); + return report; + } + + // Phase 1 only wires Yadio, so per-provider parallelism does not + // change wall-clock time yet; each fetch is awaited in sequence + // with its own [`tokio::time::timeout`] guard so one hanging API + // can't block the tick beyond `provider_timeout_seconds`. Phase 2 + // (multiple direct quoters) replaces this with a concurrent driver + // alongside the circuit-breaker integration (spec §6.5). + let timeout = Duration::from_secs(self.settings.provider_timeout_seconds); + let mut outcomes: Vec<(ProviderId, TimeoutResult)> = + Vec::with_capacity(self.providers.len()); + for p in &self.providers { + let res = tokio::time::timeout(timeout, p.provider.fetch(&self.http)).await; + outcomes.push((p.id, res)); + } + + let mut quotes_by_provider: Vec<(ProviderId, ProviderQuotes)> = + Vec::with_capacity(self.providers.len()); + for (id, outcome) in outcomes { + match outcome { + Ok(Ok(quotes)) => { + info!("price: {} ok ({} currencies)", id, quotes.len()); + quotes_by_provider.push((id, quotes)); + report.successes.push(id); + } + Ok(Err(e)) => { + warn!("price: {} error: {}", id, e); + report.failures.push((id, e.to_string())); + } + Err(_) => { + warn!( + "price: {} timed out after {}s", + id, self.settings.provider_timeout_seconds + ); + report.failures.push((id, "timeout".to_string())); + } + } + } + + // Apply per-provider currency scoping (spec §6.6) before + // aggregation. The scoping rules are configured per + // [price.providers.]; the Phase 2 §6.6 pipeline glue (fiat + // allowlist, etc.) layers on top of this. Doing the filter here + // keeps `aggregate_tick` purely numeric. + let filtered_with_ids: Vec<(ProviderId, ProviderQuotes)> = quotes_by_provider + .into_iter() + .map(|(id, quotes)| (id, self.scope_quotes(id, quotes))) + .collect(); + + let aggregates = aggregate_tick(&filtered_with_ids, self.settings.outlier_threshold_pct); + if aggregates.is_empty() { + warn!("price: tick produced no fresh aggregates — keeping last-known-good"); + return report; + } + + // Tick-wide Nostr contributors = union of every per-currency + // contributor list (spec §9 Phase 1 "contributing-source list"). + // Built from `aggregate_tick`'s provenance output so the tag + // reflects the providers whose quotes actually **survived** + // `combine`'s outlier filter, not merely those whose post-scope + // map was non-empty. + let mut contributor_set: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for agg in aggregates.values() { + contributor_set.extend(agg.contributors.iter().copied()); + } + let contributors: Vec = contributor_set.into_iter().collect(); + + let now = Utc::now().timestamp(); + self.observe_warnings(&aggregates); + self.store.update(aggregates.clone(), now); + report.fresh_currencies = aggregates.len(); + report.contributors = contributors; + + if self.settings.publish_to_nostr { + self.publish_rates_to_nostr(&aggregates, &report.contributors) + .await; + } + + report + } + + /// Apply this provider's `only`/`except` filter (spec §6.6). Done at the + /// manager boundary so the aggregator stays provider-agnostic. + fn scope_quotes(&self, id: ProviderId, quotes: ProviderQuotes) -> ProviderQuotes { + let cfg = match self.settings.providers.get(&id.to_string()) { + Some(c) => c, + None => return quotes, + }; + if cfg.only.is_none() && cfg.except.is_none() { + return quotes; + } + quotes + .into_iter() + .filter(|(currency, _)| cfg.allows_currency(currency)) + .collect() + } + + /// Emit one-shot warnings on the single-source transition: a currency + /// with one contributor warns once; gaining a second contributor + /// clears the flag so a later regression warns again (spec §10.4). + fn observe_warnings(&self, aggregates: &HashMap) { + for (currency, agg) in aggregates { + let key = currency.to_uppercase(); + if agg.sources <= 1 { + if self.mark_warned(&self.warned_single_source, &key) { + warn!("price: {} now has a single source", currency); + } + } else { + self.clear_warned(&self.warned_single_source, &key); + } + } + } + + /// Read a currency's per-BTC price. + /// + /// Phase 1 behaviour (spec §9 Phase 1): the staleness window is checked + /// and a `warn!` is logged on the **transition** into a stale state, + /// but the price is still returned. The next call after a fresh tick + /// clears the flag so future regressions warn again. Phase 4 turns + /// this into `Err(PriceTooStale)`; doing it now would refuse orders + /// that today's code happily prices, which is explicitly out of scope. + pub fn get_price(&self, currency: &str) -> Result { + let now = Utc::now().timestamp(); + let key = currency.to_uppercase(); + match self + .store + .get(currency, self.settings.max_price_staleness_seconds, now) + { + Ok(value) => { + self.observe_freshness(currency, &key, now); + Ok(value) + } + Err(PriceError::TooStale) => { + // Phase 1: log but still return the value — preserve the + // legacy "never refuse" behaviour. Phase 4 will turn this + // into a hard error. + let snap = self.store.snapshot(currency); + if let Some(entry) = snap { + let age = now.saturating_sub(entry.as_of); + if self.mark_warned(&self.warned_stale, &key) { + warn!( + "price: {} is past staleness window ({}s old) — Phase 1 still serves it", + currency, age + ); + } + 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(PriceError::NoCurrency) => { + Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)) + } + } + } + + /// Test-only escape hatch so unit tests can drive the manager without + /// the global lock. Avoid in production code; use [`Self::global`]. + #[cfg(test)] + pub fn store(&self) -> &PriceStore { + &self.store + } + + /// Inspect the entry served by the `Ok` branch of [`Self::get_price`] + /// to emit the "stale but within TTL" warning at most once, and to + /// clear the past-TTL flag once a fresh enough value lands so the + /// next slide past the TTL warns again. + fn observe_freshness(&self, currency: &str, key: &str, now: i64) { + let Some(entry) = self.store.snapshot(currency) else { + return; + }; + let age = now.saturating_sub(entry.as_of); + let one_interval = self.settings.update_interval_seconds as i64; + if age <= one_interval { + // Fully fresh — also wipe any past-TTL flag so a future slide + // past `max_price_staleness_seconds` warns once more. + self.clear_warned(&self.warned_stale, key); + return; + } + if self.mark_warned(&self.warned_stale, key) { + warn!( + "price: {} is stale ({}s old, > {}s interval)", + currency, age, one_interval + ); + } + } + + /// Insert `key` into `set`; return `true` if this is the first time + /// (the caller should warn) and `false` if the flag was already there. + /// A poisoned lock is treated as "already warned" so callers stay + /// quiet on lock failure rather than spamming after a panic. + fn mark_warned(&self, set: &RwLock>, key: &str) -> bool { + match set.write() { + Ok(mut w) => w.insert(key.to_string()), + Err(_) => false, + } + } + + fn clear_warned(&self, set: &RwLock>, key: &str) { + if let Ok(mut w) = set.write() { + w.remove(key); + } + } + + /// Publish the aggregated map to Nostr (NIP-33 kind 30078). Phase 1 + /// preserves the legacy Yadio-shaped wrapper so downstream consumers + /// keep working byte-for-byte; the `source` tag becomes the list of + /// **contributing** provider ids (spec §9 Phase 1: still effectively + /// one source, but the multi-source shape is in place). Publishing is + /// best-effort and never fails the tick. + async fn publish_rates_to_nostr( + &self, + aggregates: &HashMap, + successes: &[ProviderId], + ) { + // Build the `{"BTC": {ccy: value}}` body the legacy format used. + let rates: HashMap = aggregates + .iter() + .map(|(c, a)| (c.clone(), a.value)) + .collect(); + let mut wrapper: HashMap> = HashMap::new(); + wrapper.insert("BTC".to_string(), rates); + + let content = match serde_json::to_string(&wrapper) { + Ok(c) => c, + Err(e) => { + error!("price: failed to serialise rates for Nostr: {e}"); + return; + } + }; + + let keys = match crate::util::get_keys() { + Ok(k) => k, + Err(e) => { + error!("price: failed to get Mostro keys for Nostr publish: {e}"); + return; + } + }; + + let timestamp = Utc::now().timestamp(); + // Match legacy bitcoin_price.rs: 2× the interval, capped at 1h. + let expiration_seconds = std::cmp::min(self.settings.update_interval_seconds * 2, 3600); + let expiration = timestamp + expiration_seconds as i64; + let source_tag = sources_to_tag(successes); + let tags = Tags::from_list(vec![ + Tag::custom( + TagKind::Custom("published_at".into()), + vec![timestamp.to_string()], + ), + Tag::custom(TagKind::Custom("source".into()), vec![source_tag]), + Tag::expiration(Timestamp::from(expiration as u64)), + ]); + + let event = match crate::nip33::new_exchange_rates_event(&keys, &content, tags) { + Ok(e) => e, + Err(e) => { + error!("price: failed to build exchange-rates event: {e}"); + return; + } + }; + + let client = match crate::util::get_nostr_client() { + Ok(c) => c, + Err(e) => { + error!("price: failed to get Nostr client: {e}"); + return; + } + }; + + let timeout_duration = Duration::from_secs(30); + match tokio::time::timeout(timeout_duration, client.send_event(&event)).await { + Ok(Ok(output)) => info!( + "price: published exchange rates to Nostr ({} currencies). Output: {:?}", + aggregates.len(), + output + ), + Ok(Err(e)) => error!("price: send_event to relays failed: {e}"), + Err(_) => error!("price: timeout publishing exchange rates to Nostr (30s exceeded)"), + } + } +} + +/// Joined list of contributing provider ids for the Nostr `source` tag. +/// Sorted so the tag is deterministic across ticks with the same provider +/// set, regardless of map-iteration order. +fn sources_to_tag(ids: &[ProviderId]) -> String { + let mut names: Vec = ids.iter().map(|i| i.to_string()).collect(); + names.sort(); + names.join(",") +} + +/// Single designated extension point (spec §5.4 Step 3). Adding a new +/// provider adds exactly one match arm here — the aggregation core, the +/// store, the scheduler, and every order handler stay untouched. +fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Result, String> { + match id { + ProviderId::Yadio => Ok(Box::new(YadioProvider::new(cfg))), + // Other adapters land in their own phases (CoinGecko/currency_api/ + // Blockchain → Phase 2, El Toque → Phase 3). Reject explicitly so + // an over-eager config doesn't silently spawn nothing. + ProviderId::CoinGecko + | ProviderId::CurrencyApi + | ProviderId::Blockchain + | ProviderId::ElToque => Err(format!( + "price: provider `{id}` is configured (enabled) but not yet implemented in \ + this release — disable it or remove it from `[price.providers]` \ + (see docs/PRICE_PROVIDERS.md §7)" + )), + } +} + +/// Reason [`PriceManager::install_global`] refused — currently just one +/// variant, but exposed as an enum so the surface stays forward-compatible +/// without breaking callers (Phase 5 may grow shutdown/restart cases). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallError { + /// Another `PriceManager` is already in place. + AlreadyInstalled, +} + +impl std::fmt::Display for InstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InstallError::AlreadyInstalled => f.write_str("PriceManager already installed"), + } + } +} + +impl std::error::Error for InstallError {} + +/// Per-tick outcome used by the scheduler (for outage logging) and the +/// Phase 2 circuit breaker. +#[derive(Debug, Default)] +pub struct TickReport { + /// Providers whose [`PriceProvider::fetch`] returned `Ok` this tick. + pub successes: Vec, + /// Providers that failed or timed out, with the stringified error. + pub failures: Vec<(ProviderId, String)>, + /// Providers whose post-scope quote map was non-empty — i.e. those + /// that actually contributed at least one currency to the aggregate. + /// Distinct from `successes`: a scoped-out provider lands in + /// `successes` (it did poll OK) but **not** in `contributors`. + pub contributors: Vec, + /// Number of currencies the tick produced a fresh aggregate for. + pub fresh_currencies: usize, +} + +/// Synthesise the legacy single-source config from the top-level +/// `[mostro]` block (spec §10.1). Used when `[price]` is absent so existing +/// `settings.toml` files keep working byte-for-byte. +pub fn synthesise_legacy_price_settings( + bitcoin_price_api_url: &str, + exchange_rates_update_interval_seconds: u64, + publish_exchange_rates_to_nostr: bool, +) -> PriceSettings { + let mut providers = HashMap::new(); + providers.insert( + ProviderId::Yadio.to_string(), + ProviderConfig { + enabled: true, + url: bitcoin_price_api_url.to_string(), + fallback_urls: Vec::new(), + api_key: None, + token: None, + only: None, + except: None, + }, + ); + PriceSettings { + // Honour the legacy interval setting verbatim so an upgrade doesn't + // change a node's polling cadence behind the operator's back. + update_interval_seconds: exchange_rates_update_interval_seconds, + publish_to_nostr: publish_exchange_rates_to_nostr, + providers, + ..PriceSettings::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::price::provider::{ProviderQuotes, Quote}; + use async_trait::async_trait; + + /// In-process double for the registry's `Box` — + /// drives the manager end-to-end (poll → aggregate → store) with no + /// HTTP. Mirrors the unit-test mock in `provider.rs` but lives here so + /// we can swap it directly into `PriceManager.providers`. + struct ScriptedProvider { + id: ProviderId, + outcomes: std::sync::Mutex>>, + } + + impl ScriptedProvider { + fn new(id: ProviderId, outcomes: Vec>) -> Self { + Self { + id, + outcomes: std::sync::Mutex::new(outcomes), + } + } + } + + #[async_trait] + impl PriceProvider for ScriptedProvider { + fn id(&self) -> ProviderId { + self.id + } + async fn fetch(&self, _http: &reqwest::Client) -> Result { + let mut q = self.outcomes.lock().unwrap(); + if q.is_empty() { + // Once the script runs out, behave as a healthy noop. + return Ok(ProviderQuotes::new()); + } + q.remove(0) + } + } + + fn manager_with(scripted: ScriptedProvider) -> PriceManager { + // Disable Nostr publishing so tests don't reach the global Nostr + // client (which isn't installed in unit tests); short timeout so a + // hanging mock can't blow the test runner. + let mut settings = PriceSettings { + publish_to_nostr: false, + provider_timeout_seconds: 5, + ..PriceSettings::default() + }; + settings.providers.insert( + scripted.id.to_string(), + ProviderConfig { + enabled: true, + url: "http://test".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + PriceManager { + providers: vec![EnabledProvider { + id: scripted.id, + provider: Box::new(scripted), + }], + store: Arc::new(PriceStore::new()), + settings, + http: reqwest::Client::new(), + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + } + } + + #[tokio::test] + async fn single_yadio_tick_matches_today() { + // Spec §9 Phase 1 acceptance: with only Yadio enabled, the manager + // produces the same values as the legacy single-source path for a + // captured sample payload. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(75_899.55)); + quotes.insert("EUR".into(), Quote::PerBtc(65_393.99)); + quotes.insert("ARS".into(), Quote::PerBtc(75_899_550.0)); + + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let manager = manager_with(scripted); + + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert_eq!( + report.contributors, + vec![ProviderId::Yadio], + "yadio's quotes all survived scoping, so it contributes" + ); + assert!(report.failures.is_empty()); + assert_eq!(report.fresh_currencies, 3); + + assert!( + (manager.get_price("USD").unwrap() - 75_899.55).abs() < 1e-6, + "USD matches Yadio's value verbatim" + ); + assert!((manager.get_price("eur").unwrap() - 65_393.99).abs() < 1e-6); + } + + #[tokio::test] + async fn yadio_down_keeps_prior_values() { + // Spec §9 Phase 1 acceptance: a failed tick must leave the store + // intact, not wipe it. Two ticks: first succeeds, second errors. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes), Err(ProviderError::Http("down".into()))], + ); + let manager = manager_with(scripted); + + manager.update_all().await; + let r = manager.update_all().await; + + assert_eq!(r.successes, Vec::::new()); + assert_eq!(r.failures.len(), 1); + // Store still serves the prior tick's value — no panic, no wipe. + assert!((manager.get_price("USD").unwrap() - 50_000.0).abs() < 1e-6); + } + + #[tokio::test] + async fn no_providers_returns_no_currency() { + // Spec §9 Phase 1 acceptance: enabled=false on every provider → + // empty store; reads return an error matching "no data yet" today. + let settings = PriceSettings { + publish_to_nostr: false, + ..PriceSettings::default() + }; + let manager = PriceManager { + providers: vec![], + store: Arc::new(PriceStore::new()), + settings, + http: reqwest::Client::new(), + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + }; + let r = manager.update_all().await; + assert_eq!(r.fresh_currencies, 0); + assert!(manager.get_price("USD").is_err()); + } + + #[tokio::test] + async fn scoping_only_keeps_in_scope_currencies() { + // The El-Toque-style `only` filter is implemented here even though + // Phase 3 brings the adapter, so the Phase 1 manager already + // honours per-provider scoping. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + quotes.insert("CUP".into(), Quote::PerBtc(20_000_000.0)); + + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let mut manager = manager_with(scripted); + manager + .settings + .providers + .get_mut(&ProviderId::Yadio.to_string()) + .unwrap() + .only = Some(vec!["CUP".into()]); + + manager.update_all().await; + assert!(manager.get_price("CUP").is_ok()); + assert!(manager.get_price("USD").is_err()); + } + + #[test] + fn synthesise_legacy_builds_single_yadio_provider() { + let cfg = synthesise_legacy_price_settings("https://api.yadio.io", 600, false); + assert_eq!(cfg.update_interval_seconds, 600); + assert!(!cfg.publish_to_nostr); + let yadio = cfg + .providers + .get("yadio") + .expect("legacy migration must enable yadio"); + assert!(yadio.enabled); + assert_eq!(yadio.url, "https://api.yadio.io"); + cfg.validate().expect("synthesised config must validate"); + } + + #[test] + fn from_settings_rejects_invalid_provider_id() { + // An enabled provider whose adapter isn't yet wired must fail at + // startup, not silently produce nothing. + let mut settings = PriceSettings::default(); + settings.providers.insert( + ProviderId::CoinGecko.to_string(), + ProviderConfig { + enabled: true, + url: "https://api.coingecko.com/api/v3".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + assert!(PriceManager::from_settings(settings).is_err()); + } + + #[test] + fn from_settings_ignores_unknown_id() { + // Adding a new provider in a newer release should not break an + // older mostrod reading the same config — unknown ids are logged + // and ignored. + let mut settings = PriceSettings::default(); + settings.providers.insert( + "future_provider".to_string(), + ProviderConfig { + enabled: true, + url: "http://x".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + let m = PriceManager::from_settings(settings).expect("unknown id is non-fatal"); + assert!(m.providers.is_empty()); + } + + #[test] + fn from_settings_skips_disabled_providers() { + let mut settings = PriceSettings::default(); + settings.providers.insert( + ProviderId::Yadio.to_string(), + ProviderConfig { + enabled: false, + url: "https://api.yadio.io".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + let m = PriceManager::from_settings(settings).unwrap(); + assert!(m.providers.is_empty()); + } + + #[test] + fn sources_to_tag_is_deterministic() { + let tag = sources_to_tag(&[ProviderId::CoinGecko, ProviderId::Yadio]); + assert_eq!(tag, "coingecko,yadio"); + } + + #[tokio::test] + async fn scoped_out_provider_is_success_but_not_contributor() { + // A successful poll whose every currency is filtered by `only` + // contributes nothing to the aggregate — it must land in + // `report.successes` (it did poll OK and circuit breaker stays + // happy in Phase 2) but **not** in `report.contributors`, so the + // Nostr `source` tag never names a provider that didn't move the + // aggregate (spec §9 Phase 1: "contributing-source list"). + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let mut manager = manager_with(scripted); + // Yadio is restricted to MLC — none of its quotes match. + manager + .settings + .providers + .get_mut(&ProviderId::Yadio.to_string()) + .unwrap() + .only = Some(vec!["MLC".into()]); + + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert!( + report.contributors.is_empty(), + "scoped-out provider must not appear in the Nostr source tag" + ); + assert_eq!(report.fresh_currencies, 0); + } + + #[tokio::test] + async fn stale_warning_is_one_shot_then_re_arms_on_fresh_read() { + // Build a manager whose only stored value is intentionally past + // the TTL, then call get_price() many times: the warned_stale set + // must grow by at most one entry. A fresh tick clears the flag + // so a future regression past TTL warns again. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes.clone())]); + let mut manager = manager_with(scripted); + // Force the TTL low so the manually-written `as_of` is past it. + manager.settings.max_price_staleness_seconds = 1; + manager.settings.update_interval_seconds = 1; + + // Seed an explicitly-stale entry. + let mut agg = HashMap::new(); + agg.insert( + "USD".to_string(), + AggregateResult { + value: 50_000.0, + sources: 1, + contributors: vec![ProviderId::Yadio], + }, + ); + // 1_000_000s ago: well past any plausible TTL. + let now = Utc::now().timestamp(); + manager.store.update(agg, now - 1_000_000); + + // 10 reads against a stale value: warned_stale must end with + // exactly one entry, regardless of how many times the legacy + // code would have logged. + for _ in 0..10 { + let _ = manager.get_price("USD"); + } + assert_eq!( + manager.warned_stale.read().unwrap().len(), + 1, + "TooStale must warn at most once between fresh reads" + ); + + // Inject a fresh tick: the `Ok` branch fires and clears the flag, + // so a subsequent regression past TTL warns once more. + let mut fresh = HashMap::new(); + fresh.insert( + "USD".to_string(), + AggregateResult { + value: 50_000.0, + sources: 1, + contributors: vec![ProviderId::Yadio], + }, + ); + let fresh_now = Utc::now().timestamp(); + manager.store.update(fresh, fresh_now); + // Read once at fresh time so observe_freshness clears the flag. + let _ = manager.get_price("USD"); + assert!( + manager.warned_stale.read().unwrap().is_empty(), + "fresh read must re-arm the stale guard" + ); + } +} diff --git a/src/price/mod.rs b/src/price/mod.rs index 8c51a91a..3cde5fa4 100644 --- a/src/price/mod.rs +++ b/src/price/mod.rs @@ -1,28 +1,43 @@ //! Multi-source BTC/fiat price module (see `docs/PRICE_PROVIDERS.md`). //! -//! Phase 0 — **foundation only**. This delivers the building blocks every -//! later phase reuses, with **no wiring**: the [`PriceProvider`] trait and -//! its data types ([`provider`]), the pure aggregation core -//! ([`aggregate`]), the in-memory aggregated-price store ([`store`]), and -//! the typed `[price]` configuration ([`config`]). Nothing here makes an -//! HTTP request, touches the scheduler, or is read by an order handler; -//! that begins in Phase 1. -//! -//! Because the module is not yet referenced from `main`/handlers, it would -//! otherwise trip the `dead_code` lint in a plain `cargo build`. The -//! allow below is scoped to this module and is **removed in Phase 1**, -//! when `PriceManager` wires the registry into the scheduler and -//! `get_bitcoin_price`. -#![allow(dead_code)] +//! ## Phase 1 +//! The module is now wired into the daemon: [`PriceManager`] builds the +//! provider registry from `[price]` (or a legacy migration when the +//! section is absent, spec §10.1), the scheduler drives +//! [`PriceManager::update_all`] every `update_interval_seconds`, and +//! [`get_bitcoin_price`] / `BitcoinPriceManager::get_price` read through +//! the manager. The only adapter wired so far is [`providers::yadio`]; +//! the keyless backups (CoinGecko, currency-api, Blockchain) land in +//! Phase 2, and El Toque in Phase 3. pub mod aggregate; pub mod config; +pub mod manager; pub mod provider; +pub mod providers; pub mod store; pub use aggregate::{aggregate_tick, combine, resolve_per_base, AggregateResult}; pub use config::{PriceSettings, ProviderConfig}; +pub use manager::{synthesise_legacy_price_settings, PriceManager, TickReport}; pub use provider::{ PriceProvider, ProviderError, ProviderHealth, ProviderId, ProviderQuotes, Quote, }; pub use store::{AggregatedPrice, PriceError, PriceStore}; + +use mostro_core::error::{MostroError, ServiceError}; + +/// Read a currency's per-BTC price from the global [`PriceManager`]. +/// +/// This is the Phase 1 entry point for consumers (`util::get_bitcoin_price` +/// and the `BitcoinPriceManager::get_price` shim). When the global manager +/// has not been initialised (e.g. unit tests that don't bring up the full +/// configuration), it returns `Err(NoAPIResponse)` — the same error the +/// legacy code returned when `BITCOIN_PRICES` was empty, so callers behave +/// identically. +pub fn get_bitcoin_price(currency: &str) -> Result { + match PriceManager::global() { + Some(m) => m.get_price(currency), + None => Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)), + } +} diff --git a/src/price/provider.rs b/src/price/provider.rs index 3e4084d0..80de63b8 100644 --- a/src/price/provider.rs +++ b/src/price/provider.rs @@ -32,7 +32,7 @@ pub type ProviderQuotes = HashMap; /// tracking, and the Nostr `source` metadata. The string form (via /// [`fmt::Display`] / [`FromStr`]) matches the `[price.providers.]` /// config sub-table key. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum ProviderId { Yadio, CoinGecko, diff --git a/src/price/providers/mod.rs b/src/price/providers/mod.rs new file mode 100644 index 00000000..d056f5e6 --- /dev/null +++ b/src/price/providers/mod.rs @@ -0,0 +1,9 @@ +//! Per-provider adapters (spec §5.1, §5.4). +//! +//! Each file under this module implements [`super::PriceProvider`] for one +//! external price API. Adding a provider is one new adapter file + one +//! [`super::ProviderId`] variant + one registry arm in +//! [`super::PriceManager::from_settings`] + one config sub-table (see spec +//! §5.4). The aggregation core, store and scheduler are never touched. + +pub mod yadio; diff --git a/src/price/providers/yadio.rs b/src/price/providers/yadio.rs new file mode 100644 index 00000000..2b99546c --- /dev/null +++ b/src/price/providers/yadio.rs @@ -0,0 +1,136 @@ +//! Yadio direct BTC quoter (spec §11.1). +//! +//! Calls `GET {url}/exrates/BTC` and maps the `{"BTC": { ccy: price }}` +//! body into per-currency [`Quote::PerBtc`] entries. Yadio occasionally +//! reports `null` for currencies it currently has no rate for (e.g. +//! `"BGN": null`); the parse is lenient (`Option`) and those entries +//! are filtered out before they leave the adapter, matching the behaviour +//! of the legacy `BitcoinPriceManager`. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// Lenient response shape — `null` rates are dropped before aggregation. +#[derive(Debug, Deserialize)] +struct YadioResponse { + #[serde(rename = "BTC")] + btc: HashMap>, +} + +/// Direct BTC quoter against the Yadio API. +pub struct YadioProvider { + url: String, +} + +impl YadioProvider { + /// Build the provider from its `[price.providers.yadio]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + } + } + + /// Parse a Yadio `/exrates/BTC` payload into [`ProviderQuotes`]. + /// + /// Split out from [`PriceProvider::fetch`] so the parsing path can be + /// unit-tested against captured fixtures without standing up an HTTP + /// server (spec §10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: YadioResponse = + serde_json::from_str(body).map_err(|e| ProviderError::Parse(format!("yadio: {e}")))?; + Ok(parsed + .btc + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => Some((code, Quote::PerBtc(v))), + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for YadioProvider { + fn id(&self) -> ProviderId { + ProviderId::Yadio + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/exrates/BTC", self.url); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("yadio GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "yadio GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("yadio read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = include_str!("../../../tests/fixtures/price/yadio_btc.json"); + + #[test] + fn parses_captured_payload() { + let quotes = YadioProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // The captured payload contains USD, EUR, ARS, CUP plus a `null` + // BGN to exercise the lenient path (regression for the live Yadio + // behaviour fixed in db99f94). + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(75899.55))); + assert_eq!(quotes.get("EUR"), Some(&Quote::PerBtc(65393.99))); + assert_eq!(quotes.get("ARS"), Some(&Quote::PerBtc(75899550.0))); + assert_eq!(quotes.get("CUP"), Some(&Quote::PerBtc(28000000.0))); + assert!( + !quotes.contains_key("BGN"), + "null rates must be dropped, not surfaced as zero" + ); + } + + #[test] + fn drops_non_finite_and_non_positive() { + let body = r#"{"BTC": {"USD": 0, "EUR": -1, "GBP": 50000.0}}"#; + let quotes = YadioProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1, "only GBP is a usable rate"); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(50_000.0))); + } + + #[test] + fn parse_error_is_returned() { + let err = YadioProvider::parse("not json").unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn new_strips_trailing_slash() { + let cfg = ProviderConfig { + enabled: true, + url: "https://api.yadio.io/".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }; + let p = YadioProvider::new(&cfg); + // We rebuild the request URL by appending `/exrates/BTC`; without + // stripping the trailing slash we'd hit `//exrates/BTC`. + assert_eq!(p.url, "https://api.yadio.io"); + } +} diff --git a/src/price/store.rs b/src/price/store.rs index 319ff4ac..8ae5906f 100644 --- a/src/price/store.rs +++ b/src/price/store.rs @@ -127,6 +127,10 @@ mod tests { AggregateResult { value: *v, sources: *s, + // Store tests are agnostic to contributors — they + // exercise TTL/last-known-good semantics. Empty + // is fine since the store never reads this field. + contributors: Vec::new(), }, ) }) diff --git a/src/scheduler.rs b/src/scheduler.rs index 931302fc..62667fa7 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -2,10 +2,10 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dev_fee::run_dev_fee_cycle; use crate::app::release::do_payment; -use crate::bitcoin_price::BitcoinPriceManager; use crate::config; use crate::db::*; use crate::lightning::LndConnector; +use crate::price::PriceManager; use crate::util; use crate::LN_STATUS; use crate::{Keys, PublicKey}; @@ -19,7 +19,7 @@ use sqlx_crud::Crud; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::{error, info}; +use tracing::{error, info, warn}; use util::{enqueue_order_msg, get_nostr_relays, send_dm, update_order_event}; pub async fn start_scheduler(ctx: AppContext) { @@ -608,14 +608,24 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { async fn job_update_bitcoin_prices() { tokio::spawn(async { - let mostro_settings = Settings::get_mostro(); - let configured_interval = mostro_settings.exchange_rates_update_interval_seconds; + let Some(manager) = PriceManager::global() else { + // Defensive: `main` installs the manager before the scheduler + // is started. If that ever changes (or an embedding binary + // skips installation) this job must not panic — every other + // job keeps running. + error!("price: PriceManager not installed; skipping bitcoin price job"); + return; + }; + let configured_interval = manager.settings().update_interval_seconds; - // Validate interval: minimum 60 seconds to avoid API rate limits + // Validate interval: minimum 60 seconds to avoid API rate limits. + // Keeps the legacy guard's behaviour now that the interval moves + // from `[mostro].exchange_rates_update_interval_seconds` to + // `[price].update_interval_seconds` (spec §10.1). const MIN_INTERVAL: u64 = 60; let update_interval = if configured_interval < MIN_INTERVAL { error!( - "exchange_rates_update_interval_seconds too low: {}s (minimum: {}s). Using minimum.", + "price: update_interval_seconds too low: {}s (minimum: {}s). Using minimum.", configured_interval, MIN_INTERVAL ); MIN_INTERVAL @@ -630,8 +640,32 @@ async fn job_update_bitcoin_prices() { loop { info!("Updating Bitcoin prices"); - if let Err(e) = BitcoinPriceManager::update_prices().await { - error!("Failed to update Bitcoin prices: {}", e); + let report = manager.update_all().await; + // PriceManager already logs each provider's outcome per tick. + // The scheduler only surfaces the **outage** condition — every + // provider failed — because that's the moment ops cares about: + // the store is now reading last-known-good across the board. + if report.successes.is_empty() && !report.failures.is_empty() { + let failed: Vec = report + .failures + .iter() + .map(|(id, msg)| format!("{id}={msg}")) + .collect(); + error!( + "price: all {} providers failed this tick — serving last-known-good [{}]", + report.failures.len(), + failed.join(", ") + ); + } else if !report.failures.is_empty() { + // Partial outage: at least one provider failed but others + // covered. A summary at warn is enough; per-provider info + // is already in the manager's per-provider logs. + warn!( + "price: {}/{} providers failed this tick (still {} fresh currencies)", + report.failures.len(), + report.failures.len() + report.successes.len(), + report.fresh_currencies + ); } tokio::time::sleep(tokio::time::Duration::from_secs(update_interval)).await; } diff --git a/src/util.rs b/src/util.rs index 56de07ca..e434879d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,3 @@ -use crate::bitcoin_price::BitcoinPriceManager; use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS}; use crate::config::settings::{get_db_pool, Settings}; use crate::config::*; @@ -37,13 +36,63 @@ const MAX_RETRY: u16 = 4; // Redefined for convenience type OrderKind = mostro_core::order::Kind; +/// Resolve the Yadio base URL for the live market-quote path (`/convert`, +/// `/currencies`). +/// +/// Phase 1 transition (spec §10.1): the cached aggregate path reads +/// `[price.providers.yadio].url`, but this live path historically read the +/// legacy `[mostro].bitcoin_price_api_url`. If an operator customises only +/// the new key, the two paths would silently hit different Yadio bases. +/// Prefer the configured Yadio provider URL when it is *usable* — the provider +/// is enabled and has a non-empty URL — otherwise fall back to the legacy key. +/// A disabled or blank provider entry must not suppress that fallback. The +/// chosen URL is normalized exactly as [`YadioProvider::new`] does +/// (`trim_end_matches('/')`) so appending `/convert` / `/currencies` can never +/// produce a `//`, keeping the live and aggregate paths on an identical base. +/// Phase 4 removes this live HTTP path entirely, at which point the legacy key +/// only feeds legacy synthesis. +fn yadio_base_url() -> String { + let provider = Settings::get_price().and_then(|price| { + price + .providers + .get(&crate::price::ProviderId::Yadio.to_string()) + .map(|yadio| (yadio.url.as_str(), yadio.enabled)) + }); + let legacy = Settings::get_mostro().bitcoin_price_api_url.clone(); + select_yadio_base_url(provider, &legacy) +} + +/// Drop surrounding whitespace and any trailing slash, matching +/// [`crate::price::providers::yadio::YadioProvider::new`]. A URL that is only +/// slashes/whitespace normalizes to empty and is treated as "not configured". +fn normalize_base_url(raw: &str) -> String { + raw.trim().trim_end_matches('/').to_string() +} + +/// Pure selection logic behind [`yadio_base_url`], split out so it is unit +/// testable without the write-once global `Settings`. `provider` is +/// `(url, enabled)` for the `[price.providers.yadio]` entry when present. +/// Prefer that URL only when it is *usable* — the provider is enabled and the +/// URL is non-empty after normalization — otherwise fall back to the +/// (normalized) legacy `bitcoin_price_api_url`. +fn select_yadio_base_url(provider: Option<(&str, bool)>, legacy: &str) -> String { + if let Some((url, enabled)) = provider { + if enabled { + let url = normalize_base_url(url); + if !url.is_empty() { + return url; + } + } + } + normalize_base_url(legacy) +} + pub async fn retries_yadio_request( req_string: &str, fiat_code: &str, ) -> Result<(Option, bool), MostroError> { // Get Fiat list and check if currency exchange is available - let mostro_settings = Settings::get_mostro(); - let api_req_string = format!("{}/currencies", mostro_settings.bitcoin_price_api_url); + let api_req_string = format!("{}/currencies", yadio_base_url()); let fiat_list_check = HTTP_CLIENT .get(api_req_string) .send() @@ -69,7 +118,7 @@ pub async fn retries_yadio_request( } pub fn get_bitcoin_price(fiat_code: &str) -> Result { - BitcoinPriceManager::get_price(fiat_code) + crate::price::get_bitcoin_price(fiat_code) } /// Request market quote from Yadio to have sats amount at actual market price @@ -79,10 +128,11 @@ pub async fn get_market_quote( premium: i64, ) -> Result { // Add here check for market price - let mostro_settings = Settings::get_mostro(); let req_string = format!( "{}/convert/{}/{}/BTC", - mostro_settings.bitcoin_price_api_url, fiat_amount, fiat_code + yadio_base_url(), + fiat_amount, + fiat_code ); info!("Requesting API price: {}", req_string); @@ -1551,6 +1601,56 @@ mod tests { }); } + #[test] + fn select_yadio_base_url_prefers_enabled_provider() { + // Enabled provider with a usable URL wins over the legacy key. + assert_eq!( + select_yadio_base_url( + Some(("https://provider.example", true)), + "https://legacy.example" + ), + "https://provider.example" + ); + } + + #[test] + fn select_yadio_base_url_strips_trailing_slash_like_provider_new() { + // A configured trailing slash must not survive — otherwise appending + // `/convert` yields `//convert`. Matches `YadioProvider::new`. + assert_eq!( + select_yadio_base_url( + Some(("https://api.yadio.io/", true)), + "https://legacy.example" + ), + "https://api.yadio.io" + ); + // Whitespace + multiple trailing slashes both normalized away. + assert_eq!( + select_yadio_base_url(Some((" https://api.yadio.io// ", true)), "ignored"), + "https://api.yadio.io" + ); + // The legacy fallback is normalized the same way. + assert_eq!( + select_yadio_base_url(None, "https://legacy.example/"), + "https://legacy.example" + ); + } + + #[test] + fn select_yadio_base_url_falls_back_when_provider_unusable() { + let legacy = "https://legacy.example"; + // Disabled provider → fall back to legacy even with a URL set. + assert_eq!( + select_yadio_base_url(Some(("https://provider.example", false)), legacy), + legacy + ); + // Enabled but blank / slash-only URL → fall back to legacy. + assert_eq!(select_yadio_base_url(Some((" ", true)), legacy), legacy); + assert_eq!(select_yadio_base_url(Some(("/", true)), legacy), legacy); + // No provider entry at all → legacy. + assert_eq!(select_yadio_base_url(None, legacy), legacy); + } + async fn setup_orders_pool() -> SqlitePool { let pool = SqlitePoolOptions::new() .max_connections(1) diff --git a/tests/fixtures/price/yadio_btc.json b/tests/fixtures/price/yadio_btc.json new file mode 100644 index 00000000..383de44a --- /dev/null +++ b/tests/fixtures/price/yadio_btc.json @@ -0,0 +1,11 @@ +{ + "BTC": { + "USD": 75899.55, + "EUR": 65393.99, + "ARS": 75899550.0, + "CUP": 28000000.0, + "BGN": null + }, + "base": "BTC", + "timestamp": 1779480604069 +} From af8f05d18e5839c471e78eef2c2d4973ab13f9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 12 Jun 2026 15:28:15 +0200 Subject: [PATCH 07/23] =?UTF-8?q?feat(bond):=20Phase=206=20=E2=80=94=20ran?= =?UTF-8?q?ge-order=20maker=20bond=20with=20proportional=20slashes=20(#770?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * docs(bond): reference PR #770 for Phase 6 Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) * 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) * 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) * 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) * 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) * fix(bond): close the stale-snapshot over-refund race at range close ermeme review on PR #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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/ANTI_ABUSE_BOND.md | 88 +- ...20260611120000_bond_slice_slash_unique.sql | 18 + src/app/admin_cancel.rs | 12 + src/app/admin_settle.rs | 12 + src/app/bond/db.rs | 295 ++- src/app/bond/flow.rs | 9 +- src/app/bond/mod.rs | 4 +- src/app/bond/payout.rs | 199 +- src/app/bond/slash.rs | 1633 ++++++++++++++++- src/app/cancel.rs | 21 +- src/app/release.rs | 52 +- src/bitcoin_price.rs | 13 + src/scheduler.rs | 39 +- src/util.rs | 104 +- 14 files changed, 2393 insertions(+), 106 deletions(-) create mode 100644 migrations/20260611120000_bond_slice_slash_unique.sql diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index 46f4e68c..82ab2912 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -192,8 +192,8 @@ slash path. | 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | ✅ shipped (PR #743) | | 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | ✅ shipped (PR #744) | | 4.5 | Re-prompt the winner for a fresh payout invoice after `send_payment` retries exhaust, instead of stranding the bond in `Failed` ([issue #750](https://github.com/MostroP2P/mostro/issues/750)) | 3 | pending | -| 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | pending | -| 6 | Maker bond for **range orders** with proportional slashes | 5 | pending | +| 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | ✅ shipped (PR #767) | +| 6 | Maker bond for **range orders** with proportional slashes | 5 | ✅ shipped (PR #770) | | 7 | Timeout slash for maker bond | 5 | pending | | 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | pending | @@ -206,15 +206,16 @@ orthogonal to the slash-direction phases — it can land any time after Phase 3, and is numbered 4.5 only because it was reported from field testing after Phase 4 shipped. -**Status as of this revision.** Phases 0 through 4 are merged on -`main` (PRs #712, #719, #736, #737, #738, #743, #744). The -`mostro-core` pin in `Cargo.toml` is **0.11.5**, which carries every -protocol variant those phases need (`Status::WaitingTakerBond`, +**Status as of this revision.** Phases 0 through 5 (including 4.5) are +merged on `main` (PRs #712, #719, #736, #737, #738, #743, #744, #755, +#767), and Phase 6 is implemented. The `mostro-core` pin in `Cargo.toml` +is **0.12.1**, which carries every protocol variant those phases need +(`Status::WaitingTakerBond`, `Status::WaitingMakerBond`, `Action::PayBondInvoice`, `Payload::BondResolution`, `Action::AddBondInvoice`, `Payload::BondPayoutRequest`, `Action::BondInvoiceAccepted`, `Action::BondPayoutCompleted`, -`Action::BondSlashed`). Phase 4.5 and Phases 5–8 are not yet -implemented. +`Action::BondSlashed`). Phase 6 is daemon-only (no protocol/schema +change). Phases 7–8 are not yet implemented. --- @@ -1871,6 +1872,77 @@ publication time and is not repriced. Dependent on Phase 5. This is the only genuinely subtle phase; keep the review bar high. +**Implementation notes (as shipped).** Daemon-only — no `mostro-core` +change (reuses the Phase 2 dispute, Phase 3 payout, and Phase 4/5 +mechanisms) and **no new migration** (the Phase 0 `bonds` schema already +carries `parent_bond_id` / `child_order_id` / `slashed_share_sats`). + +- **Payout timing: settle-at-close ("Option A").** The parent hold invoice + stays `Locked` for the whole range life. Each maker slice slash inserts a + child row (`PendingPayout`) and accumulates `slashed_share_sats` **without + settling**. The Phase 3 payout scheduler skips any child row whose parent + is still `Locked` (`child_payout_blocked_by_locked_parent`). At range + close the parent HTLC is settled **once**, the per-child counterparty + shares are paid, and the unslashed remainder is refunded to the maker. + Mostro never fronts liquidity. (The alternative — eager per-child payout — + was rejected to keep the "`PendingPayout` ⇒ sats already claimable" + invariant.) At close, the parent HTLC is settled **first** (while the bond + is still `Locked`), and only then are the `Locked → Slashed` CAS, the + maker-refund row insert, and the child claim-window re-anchor written in one + atomic SQLite transaction — so `Locked` is the sole in-flight state and the + `Locked`-keyed reconciliation sweep covers every crash window (a retry + re-settles harmlessly, since LND reports "already settled"). +- **Slash share is computed in fiat, not sats.** The literal §11.2 formula + divides sats by sats, but the slice sats and the bond-notional sats are + quoted at *different* prices (take time vs publication time), so that + ratio drifts with the BTC price. The daemon instead uses + `share_fraction = slice.fiat_amount / root.max_amount` (both fiat — the + ratio is price-invariant and equals the sats formula when the price is + stable). This needs no `parent_max_sats` column. The cumulative slashed + share is clamped to the locked bond amount as a rounding guard. +- **The maker bond lives on the range *root*.** A slash on any slice walks + `range_parent_id` to the root (`find_maker_bond_for_order`) to find the + single maker bond. The child slash row's `order_id` and maker-side + `pubkey` are the *slice's*, so the Phase 3 recipient resolver pays the + slice's winning counterparty unchanged. The maker-refund row is marked by + `parent_bond_id IS NOT NULL AND child_order_id IS NULL` and pays + `bond.pubkey` (the maker) directly (`resolve_payout_recipient`). +- **Range close is detected at every terminal hook** *except* a successful + release that spawns a remainder (the range continues then — the maker + bond stays `Locked`). `resolve_range_maker_bond_at_close[_or_warn]` is + invoked from `release_action` (no child spawned), `admin_settle` / + `admin_cancel` (a dispute ends the range), the three `cancel.rs` order- + termination paths, and the scheduler's `pending_expiry`. It is idempotent + (a CAS `Locked → Slashed`) and a no-op for non-range / already-resolved + bonds. Maker-responsible **timeout** slashes for range bonds land in + Phase 7; until then a maker-timeout cancel releases (no slash). +- **"One slash row per slice" is enforced at the schema level.** Besides the + atomic `INSERT ... WHERE NOT EXISTS` in `record_maker_slice_slash` (which + already wins/loses the TOCTOU race correctly), a partial UNIQUE index on + `bonds(parent_bond_id, child_order_id) WHERE parent_bond_id IS NOT NULL AND + child_order_id IS NOT NULL` (migration `20260611120000`) makes the invariant + hold for any future caller (e.g. the Phase 7 maker-timeout slash) and + survive a code regression that drops the guard. SQLite treats NULLs as + distinct, so parent rows, taker bonds, and the maker-refund row + (`child_order_id NULL`) are unconstrained. The insert path treats a + constraint violation as the same idempotent no-op as `rows_affected = 0`. +- **A reconciliation sweep retries a stranded close.** Because the order's + terminal-state commit is never gated on close success (best-effort, §8.2), + a transient LND/DB failure in `resolve_range_maker_bond_at_close` leaves the + parent `Locked` with no further retry from the terminal hooks — blocking + every slashed slice's payout until the CLTV safety net. The scheduler job + `job_reconcile_stranded_maker_bonds` (every 5 min) scans for `Locked` maker + parent bonds whose entire range tree (root + every `range_parent_id` + descendant) is in a terminal status and re-invokes the (idempotent) close + for each. A legitimately-open range — whose maker bond is `Locked` by + design — is never touched, since at least one descendant is non-terminal. + So a close failure **no longer relies solely on the CLTV safety net**; the + sweep is the primary recovery and CLTV is the last-resort backstop. The + range-tree terminality check walks `range_parent_id` downward with a + recursive CTE that uses `UNION` (dedup) so a corrupt cycle can't hang the + tick, and the scan isolates per-root failures (log + `continue`) so one + bad chain never blocks reconciliation of the other stranded bonds. + ### 11.1 Data model Phase 0 already shipped the columns. The maker posts **one** hold diff --git a/migrations/20260611120000_bond_slice_slash_unique.sql b/migrations/20260611120000_bond_slice_slash_unique.sql new file mode 100644 index 00000000..41b99c9e --- /dev/null +++ b/migrations/20260611120000_bond_slice_slash_unique.sql @@ -0,0 +1,18 @@ +-- Phase 6 hardening: enforce the "one slash row per slice" invariant at the +-- schema level, not just in `record_maker_slice_slash`'s atomic +-- `INSERT ... WHERE NOT EXISTS`. +-- +-- The application insert already wins/loses the TOCTOU race correctly (the +-- loser sees `rows_affected = 0`), but a unique index makes the invariant +-- hold for ANY future caller (e.g. the Phase 7 maker-timeout slash) and +-- survives a code regression that drops the existence check. +-- +-- Partial so it constrains ONLY child slash rows: SQLite treats NULLs as +-- distinct, so a plain unique index on (parent_bond_id, child_order_id) would +-- already exempt parent rows (both NULL), taker bonds (both NULL), and +-- maker-refund rows (child_order_id NULL). The explicit `WHERE … IS NOT NULL` +-- predicate keeps the index small (only the child rows it governs) and makes +-- the intent unmistakable. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bonds_parent_child_unique + ON bonds (parent_bond_id, child_order_id) + WHERE parent_bond_id IS NOT NULL AND child_order_id IS NOT NULL; diff --git a/src/app/admin_cancel.rs b/src/app/admin_cancel.rs index b652de9b..3ab93edb 100644 --- a/src/app/admin_cancel.rs +++ b/src/app/admin_cancel.rs @@ -238,5 +238,17 @@ pub async fn admin_cancel_action( ); } + // Phase 6: a dispute resolution ends the range (no remainder is + // republished), so resolve the maker bond at close — settle the parent + // HTLC once and refund the unslashed remainder if any slice was slashed, + // otherwise release. A no-op for non-range maker bonds and for orders + // with no maker bond. + if let Err(e) = bond::resolve_range_maker_bond_at_close(pool, ln_client, &order).await { + tracing::warn!( + order_id = %order.id, + "admin_cancel: maker bond close failed: {}", e + ); + } + Ok(()) } diff --git a/src/app/admin_settle.rs b/src/app/admin_settle.rs index 49d5ccdc..507fa39f 100644 --- a/src/app/admin_settle.rs +++ b/src/app/admin_settle.rs @@ -229,6 +229,18 @@ pub async fn admin_settle_action( ); } + // Phase 6: a dispute resolution ends the range (no remainder is + // republished), so resolve the maker bond at close — settle the parent + // HTLC once and refund the unslashed remainder if any slice was slashed, + // otherwise release. A no-op for non-range maker bonds (already handled + // inline by `apply_bond_resolution`) and for orders with no maker bond. + if let Err(e) = bond::resolve_range_maker_bond_at_close(pool, ln_client, &order_updated).await { + tracing::warn!( + order_id = %order_updated.id, + "admin_settle: maker bond close failed: {}", e + ); + } + let _ = do_payment(ctx, order_updated, request_id).await; Ok(()) diff --git a/src/app/bond/db.rs b/src/app/bond/db.rs index d7d356fa..e197cd5f 100644 --- a/src/app/bond/db.rs +++ b/src/app/bond/db.rs @@ -3,7 +3,8 @@ //! Phase 0 exposes the CRUD surface later phases will need. Nothing in //! this module hits LND or the Nostr client — it's purely storage. -use mostro_core::error::{MostroError::MostroInternalErr, ServiceError}; +use mostro_core::error::{MostroError, MostroError::MostroInternalErr, ServiceError}; +use mostro_core::order::{Order, Status}; use sqlx::{Pool, Sqlite}; use sqlx_crud::Crud; use uuid::Uuid; @@ -11,6 +12,12 @@ use uuid::Uuid; use super::model::Bond; use super::types::{BondRole, BondState}; +/// Defensive upper bound on a `range_parent_id` walk. The real chain +/// length is bounded by how many slices a range can be split into +/// (`max_amount / min_amount`), always small; this cap exists only so a +/// corrupt cycle in the DB can never hang the daemon. +const MAX_RANGE_CHAIN_DEPTH: usize = 1024; + /// Insert a new bond row. Returns the persisted `Bond`. pub async fn create_bond( pool: &Pool, @@ -61,6 +68,20 @@ pub async fn find_bonds_by_state( .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) } +/// Look up a bond row by its primary key. Used by the Phase 3 payout +/// scheduler to check a child slash row's parent state (skip while the +/// parent HTLC is still `Locked`, i.e. before range close). +pub async fn find_bond_by_id( + pool: &Pool, + id: Uuid, +) -> Result, mostro_core::error::MostroError> { + sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ? LIMIT 1") + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + /// Look up a bond row by its Lightning payment hash. The hash uniquely /// identifies the bond hold invoice, so this is what the LND subscriber /// uses to correlate incoming invoice events back to a `Bond`. @@ -148,6 +169,149 @@ pub async fn find_active_bond_by_taker( .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) } +/// Walk the `range_parent_id` chain from `order` up to the range root — +/// the order that owns the maker bond (Phase 6). +/// +/// `range_parent_id` is a linked list to the *immediate* parent slice, not +/// a star to the root (see `create_base_order` in `app::release`), so the +/// maker bond lives on whichever order has `range_parent_id IS NULL`. A +/// non-range order or an already-root order returns itself. The walk is +/// bounded by [`MAX_RANGE_CHAIN_DEPTH`]; a missing parent row (should never +/// happen) terminates the walk at the deepest order we could load. +pub async fn find_range_root_order( + pool: &Pool, + order: Order, +) -> Result { + let mut current = order; + for _ in 0..MAX_RANGE_CHAIN_DEPTH { + let Some(parent_id) = current.range_parent_id else { + return Ok(current); + }; + match Order::by_id(pool, parent_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? + { + Some(parent) => current = parent, + None => return Ok(current), + } + } + Err(MostroInternalErr(ServiceError::UnexpectedError( + "range_parent_id chain exceeded max depth (possible cycle)".to_string(), + ))) +} + +/// Find the parent **maker** bond governing `order`, walking the range +/// chain to its root first. +/// +/// A maker slash (or release) can land on any slice in a range chain, but +/// there is only ever one maker bond and it lives on the root order. This +/// resolves that single bond from any slice. Returns `None` when no maker +/// bond exists (feature off, `apply_to` excludes the maker, or the bond +/// was already released). +pub async fn find_maker_bond_for_order( + pool: &Pool, + order: &Order, +) -> Result, MostroError> { + let root = find_range_root_order(pool, order.clone()).await?; + find_bond_by_order_and_role(pool, root.id, BondRole::Maker).await +} + +/// Every child slash row that belongs to `parent_bond_id` (Phase 6 +/// range-order accounting). Ordered oldest-first for deterministic +/// iteration at parent-close. +pub async fn find_child_slashes_for_parent( + pool: &Pool, + parent_bond_id: Uuid, +) -> Result, MostroError> { + sqlx::query_as::<_, Bond>( + "SELECT * FROM bonds WHERE parent_bond_id = ? ORDER BY created_at ASC", + ) + .bind(parent_bond_id) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Order statuses from which a range slice can never re-activate. Once +/// every order in a range tree has reached one of these, the maker bond is +/// safe to close — no descendant can still draw against it. Kept as the +/// complement of the in-flight states (`Pending`, `Active`, the `Waiting*` +/// states, `FiatSent`, `SettledHoldInvoice`, `Dispute`, `InProgress`) so a +/// new in-flight status defaults to "not terminal" (conservative: the sweep +/// won't prematurely close a bond it doesn't understand). +const TERMINAL_ORDER_STATUSES: [Status; 7] = [ + Status::Success, + Status::Canceled, + Status::CanceledByAdmin, + Status::SettledByAdmin, + Status::CompletedByAdmin, + Status::CooperativelyCanceled, + Status::Expired, +]; + +/// Every `Locked` **parent** maker bond (`role = 'maker'`, +/// `parent_bond_id IS NULL`). The reconciliation sweep +/// (`reconcile_stranded_range_maker_bonds`) starts from this set and then +/// filters to the ones whose whole range tree has terminated. Child slash +/// rows and refund rows (both `parent_bond_id IS NOT NULL`) are excluded. +pub async fn find_locked_maker_parent_bonds(pool: &Pool) -> Result, MostroError> { + let locked = BondState::Locked.to_string(); + let maker = BondRole::Maker.to_string(); + sqlx::query_as::<_, Bond>( + "SELECT * FROM bonds \ + WHERE state = ? AND role = ? AND parent_bond_id IS NULL \ + ORDER BY created_at ASC", + ) + .bind(locked) + .bind(maker) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Is the whole range tree rooted at `root_id` fully terminal — i.e. does no +/// order reachable from the root via `range_parent_id` sit in a non-terminal +/// status? +/// +/// Walks the `range_parent_id` linked list **downwards** with a recursive +/// CTE (the inverse of [`find_range_root_order`], which walks up). Returns +/// `true` only when every order in the tree — the root included — is in a +/// [`TERMINAL_ORDER_STATUSES`] state, so the maker bond can be safely closed. +/// A non-range or already-root order tree is just the single root row. +pub async fn range_tree_fully_terminal( + pool: &Pool, + root_id: Uuid, +) -> Result { + let placeholders = TERMINAL_ORDER_STATUSES + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + // `UNION` (not `UNION ALL`) is deliberate: it deduplicates, so a corrupt + // `range_parent_id` cycle (e.g. A↔B) terminates the recursion instead of + // looping unbounded and hanging the scheduler tick. This mirrors the + // `MAX_RANGE_CHAIN_DEPTH` guard on the upward walk in + // `find_range_root_order`. Dedup is safe here because the result is only + // reduced to `COUNT(*) … == 0` below — the exact count is never used. + let sql = format!( + "WITH RECURSIVE tree(id, status) AS ( \ + SELECT id, status FROM orders WHERE id = ? \ + UNION \ + SELECT o.id, o.status FROM orders o JOIN tree t ON o.range_parent_id = t.id \ + ) \ + SELECT COUNT(*) FROM tree WHERE status NOT IN ({placeholders})" + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(root_id); + for status in TERMINAL_ORDER_STATUSES { + query = query.bind(status.to_string()); + } + let non_terminal = query + .fetch_one(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + Ok(non_terminal == 0) +} + /// Update a bond row by primary key. Returns the persisted `Bond`. pub async fn update_bond( pool: &Pool, @@ -190,6 +354,12 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); + sqlx::query(include_str!( + "../../../migrations/20260611120000_bond_slice_slash_unique.sql" + )) + .execute(&pool) + .await + .expect("bond_slice_slash_unique migration"); // SQLite doesn't enforce FKs unless asked. Turn them on so the FK to // `orders` is a real constraint in tests (mirrors production). sqlx::query("PRAGMA foreign_keys = ON") @@ -213,10 +383,133 @@ mod tests { .expect("insert parent order"); } + /// Insert an order with an explicit status and optional `range_parent_id`, + /// so the range-tree-terminal walk can be exercised over a real chain. + async fn insert_order_with( + pool: &Pool, + id: Uuid, + status: Status, + range_parent_id: Option, + ) { + sqlx::query( + r#"INSERT INTO orders ( + id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, range_parent_id, created_at, expires_at + ) VALUES (?, 'sell', ?, ?, 0, 'ln', 1000, 'USD', 10, ?, 0, 0)"#, + ) + .bind(id) + .bind(id.simple().to_string()) + .bind(status.to_string()) + .bind(range_parent_id) + .execute(pool) + .await + .expect("insert order with status"); + } + fn dummy_bond(order_id: Uuid, role: BondRole) -> Bond { Bond::new_requested(order_id, "a".repeat(64), role, 1_500) } + #[tokio::test] + async fn range_tree_terminal_walks_descendants() { + // root <- child (range_parent_id chain). The tree is "fully terminal" + // only when BOTH the root and every descendant are terminal. + let pool = setup_pool().await; + let root = Uuid::new_v4(); + let child = Uuid::new_v4(); + insert_order_with(&pool, root, Status::CooperativelyCanceled, None).await; + insert_order_with(&pool, child, Status::Active, Some(root)).await; + + // A still-Active descendant keeps the tree non-terminal. + assert!( + !range_tree_fully_terminal(&pool, root).await.unwrap(), + "an Active descendant must make the tree non-terminal" + ); + + // Terminate the descendant → the whole tree is terminal. + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::Expired.to_string()) + .bind(child) + .execute(&pool) + .await + .unwrap(); + assert!( + range_tree_fully_terminal(&pool, root).await.unwrap(), + "root + descendant both terminal → tree terminal" + ); + + // A non-terminal root alone also blocks (single-row tree). + let lone = Uuid::new_v4(); + insert_order_with(&pool, lone, Status::Pending, None).await; + assert!(!range_tree_fully_terminal(&pool, lone).await.unwrap()); + } + + #[tokio::test] + async fn range_tree_terminal_is_cycle_safe() { + // A corrupt `range_parent_id` cycle (A↔B) must NOT hang the query: + // the CTE uses `UNION` (dedup), so the walk terminates. This test + // completing at all proves there is no unbounded loop. + let pool = setup_pool().await; + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + // Insert both first (no parent), then point them at each other. + insert_order_with(&pool, a, Status::Active, None).await; + insert_order_with(&pool, b, Status::Active, None).await; + sqlx::query("UPDATE orders SET range_parent_id = ? WHERE id = ?") + .bind(b) + .bind(a) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE orders SET range_parent_id = ? WHERE id = ?") + .bind(a) + .bind(b) + .execute(&pool) + .await + .unwrap(); + + // Returns promptly without error; both nodes Active → non-terminal. + assert!(!range_tree_fully_terminal(&pool, a).await.unwrap()); + + // Terminate both → the cyclic tree reads as terminal (still no hang). + sqlx::query("UPDATE orders SET status = ?") + .bind(Status::Expired.to_string()) + .execute(&pool) + .await + .unwrap(); + assert!(range_tree_fully_terminal(&pool, a).await.unwrap()); + } + + #[tokio::test] + async fn locked_maker_parent_bonds_excludes_children() { + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + let child_order_id = Uuid::new_v4(); + insert_parent_order(&pool, order_id).await; + insert_parent_order(&pool, child_order_id).await; + + // A Locked maker parent. + let mut parent = dummy_bond(order_id, BondRole::Maker); + parent.state = BondState::Locked.to_string(); + let parent = create_bond(&pool, parent).await.unwrap(); + + // A child slash row (PendingPayout) — must be excluded. + let mut child = dummy_bond(order_id, BondRole::Maker); + child.parent_bond_id = Some(parent.id); + child.child_order_id = Some(child_order_id); + child.state = BondState::PendingPayout.to_string(); + create_bond(&pool, child).await.unwrap(); + + // A Locked taker bond — wrong role, excluded. + let mut taker = dummy_bond(child_order_id, BondRole::Taker); + taker.state = BondState::Locked.to_string(); + create_bond(&pool, taker).await.unwrap(); + + let locked = find_locked_maker_parent_bonds(&pool).await.unwrap(); + assert_eq!(locked.len(), 1); + assert_eq!(locked[0].id, parent.id); + } + #[tokio::test] async fn insert_and_fetch_by_order_and_role() { let pool = setup_pool().await; diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index afc23432..b24305e4 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -77,11 +77,12 @@ pub fn taker_bond_required() -> bool { /// True when the configuration requires the **maker** to post a bond. /// -/// Phase 5 gate, symmetric to [`taker_bond_required`]. `publish_order` +/// Phase 5/6 gate, symmetric to [`taker_bond_required`]. `publish_order` /// asks this question before publishing a new order to Nostr: when it is -/// true (and the order is non-range — Phase 6 handles range makers), the -/// order is parked at [`Status::WaitingMakerBond`] and no NIP-33 event is -/// emitted until the maker locks the bond. +/// true the order is parked at [`Status::WaitingMakerBond`] and no NIP-33 +/// event is emitted until the maker locks the bond. Both fixed-amount +/// (Phase 5) and range (Phase 6) makers take this path; range makers size +/// the bond against `max_amount` and slash proportionally per slice. pub fn maker_bond_required() -> bool { Settings::get_bond() .filter(|cfg| cfg.enabled) diff --git a/src/app/bond/mod.rs b/src/app/bond/mod.rs index 981a79bc..daf8b81f 100644 --- a/src/app/bond/mod.rs +++ b/src/app/bond/mod.rs @@ -28,6 +28,8 @@ pub use model::Bond; pub use payout::{add_bond_invoice_action, run_bond_payout_cycle}; pub use slash::{ apply_bond_resolution, extract_bond_resolution, notify_bond_slashed, - slash_or_release_on_timeout, validate_bond_resolution, + reconcile_stranded_range_maker_bonds, resolve_range_maker_bond_at_close, + resolve_range_maker_bond_at_close_or_warn, slash_or_release_on_timeout, + validate_bond_resolution, }; pub use types::{BondRole, BondSlashReason, BondState}; diff --git a/src/app/bond/payout.rs b/src/app/bond/payout.rs index c432f614..1a2a6031 100644 --- a/src/app/bond/payout.rs +++ b/src/app/bond/payout.rs @@ -75,7 +75,7 @@ use crate::lightning::invoice::{decode_invoice, is_valid_invoice}; use crate::lightning::{routing_fee_cap_sats, LndConnector}; use crate::util::{bytes_to_string, enqueue_order_msg}; -use super::db::find_bonds_by_state; +use super::db::{find_bond_by_id, find_bonds_by_state}; use super::model::Bond; use super::types::{BondSlashReason, BondState}; @@ -156,11 +156,51 @@ pub async fn run_bond_payout_cycle(pool: &Pool, ln_client: &mut LndConne /// [`PaymentFailureKind`]. /// /// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 +/// Phase 6 — should the payout scheduler **skip** `bond` this tick because +/// it is a child payout row (slice slash or maker refund) whose parent +/// range bond is still `Locked`? +/// +/// The parent HTLC is settled only at range close +/// (`resolve_range_maker_bond_at_close` → parent `Slashed`); until then the +/// sats backing the child are not in Mostro's wallet, so the child must not +/// request an invoice or attempt `send_payment`. A missing parent (should +/// never happen) is treated as "skip" defensively. Non-child rows +/// (`parent_bond_id IS NULL`) are never blocked. +async fn child_payout_blocked_by_locked_parent( + pool: &Pool, + bond: &Bond, +) -> Result { + let Some(parent_id) = bond.parent_bond_id else { + return Ok(false); + }; + match find_bond_by_id(pool, parent_id).await? { + Some(parent) => Ok(parent.state == BondState::Locked.to_string()), + None => { + warn!( + bond_id = %bond.id, + parent_bond_id = %parent_id, + "bond payout: child row's parent bond is missing; skipping this tick" + ); + Ok(true) + } + } +} + async fn process_one_bond( pool: &Pool, ln_client: &mut LndConnector, bond: &Bond, ) -> Result<(), MostroError> { + // Phase 6 — a child payout row (slice slash or maker refund) must not + // be driven while its parent range bond is still `Locked`: the parent + // HTLC has not been settled, so the sats are not yet in Mostro's wallet + // and there is nothing to pay out from. `resolve_range_maker_bond_at_close` + // settles the parent (→ `Slashed`) at range close, which unblocks every + // child row on the next scheduler tick. Skip silently until then. + if child_payout_blocked_by_locked_parent(pool, bond).await? { + return Ok(()); + } + let cfg = Settings::get_bond(); let claim_window_seconds = cfg .map(|c| c.payout_claim_window_days as i64 * 86_400) @@ -352,7 +392,7 @@ async fn request_payout_invoice( ))) })?; - let recipient_pubkey = match resolve_recipient(&order, bond, reason)? { + let recipient_pubkey = match resolve_payout_recipient(&order, bond, reason)? { Some(pk) => pk, None => { warn!( @@ -997,6 +1037,29 @@ fn resolve_recipient( Ok(pk) } +/// Payout recipient for any `PendingPayout` row, Phase-6 aware. +/// +/// - **Maker-refund row** (Phase 6: `parent_bond_id` set, `child_order_id` +/// NULL) → the recipient is the **maker themselves** (`bond.pubkey`), not +/// a trade counterparty. This is the unslashed remainder being returned +/// after a partial range slash. +/// - **Everything else** — a normal slash row, or a Phase 6 *slice-slash* +/// child (whose `order_id` is the slice order and whose `pubkey` is the +/// maker's slice-side key) — resolves via [`resolve_recipient`] to the +/// non-`bond.pubkey` side of the order, i.e. the winning counterparty. +fn resolve_payout_recipient( + order: &Order, + bond: &Bond, + reason: BondSlashReason, +) -> Result, MostroError> { + if bond.parent_bond_id.is_some() && bond.child_order_id.is_none() { + let pk = PublicKey::from_str(&bond.pubkey) + .map_err(|e| MostroInternalErr(ServiceError::UnexpectedError(e.to_string())))?; + return Ok(Some(pk)); + } + resolve_recipient(order, bond, reason) +} + /// Build the `SmallOrder` carried by bond-payout messages /// (`AddBondInvoice`, `BondInvoiceAccepted`, `BondPayoutCompleted`). /// `order.amount` carries the **counterparty share** — the figure the @@ -1138,7 +1201,7 @@ async fn notify_payout_completed(pool: &Pool, bond: &Bond, counterparty_ return; } }; - match resolve_recipient(&order, bond, reason) { + match resolve_payout_recipient(&order, bond, reason) { Ok(Some(recipient)) => { enqueue_payout_ack( &order, @@ -1223,7 +1286,24 @@ pub async fn add_bond_invoice_action( }; let sender = event.sender; - let bond = find_recoverable_bond_for_recipient(pool, order_id, &sender.to_string()).await?; + // Phase 6: a single order can carry more than one payout debt to the + // *same* recipient (e.g. under `apply_to = both`, a range root may owe + // the maker both a taker-slash counterparty share and an unslashed + // refund). Decode the submitted invoice's amount so the finder can + // disambiguate by `counterparty_share` when several candidates share a + // recipient. `None` (amountless invoice / decode failure) falls back to + // the legacy recipient-only match. + let invoice_share_sats = decode_invoice(&payment_request) + .ok() + .and_then(|inv| inv.amount_milli_satoshis()) + .map(|msat| (msat / 1000) as i64); + let bond = find_recoverable_bond_for_recipient( + pool, + order_id, + &sender.to_string(), + invoice_share_sats, + ) + .await?; let bond = match bond { Some(b) => b, None => { @@ -1439,6 +1519,7 @@ async fn find_recoverable_bond_for_recipient( pool: &Pool, order_id: Uuid, sender_pubkey: &str, + expected_share_sats: Option, ) -> Result, MostroError> { let bonds: Vec = sqlx::query_as::<_, Bond>( "SELECT * FROM bonds \ @@ -1464,6 +1545,10 @@ async fn find_recoverable_bond_for_recipient( None => return Ok(None), }; + // Collect every recoverable row whose recipient matches the sender. + // Usually there is exactly one; Phase 6 can produce two debts to the + // same recipient on one order (see caller). + let mut matches: Vec = Vec::new(); for bond in bonds { let reason = match bond .slashed_reason @@ -1473,13 +1558,30 @@ async fn find_recoverable_bond_for_recipient( Some(r) => r, None => continue, }; - if let Some(recipient) = resolve_recipient(&order, &bond, reason)? { + if let Some(recipient) = resolve_payout_recipient(&order, &bond, reason)? { if recipient.to_string() == sender_pubkey { - return Ok(Some(bond)); + matches.push(bond); } } } - Ok(None) + + // Disambiguate by the submitted invoice amount when more than one debt + // shares the recipient: prefer the row whose counterparty share equals + // the invoice's amount. Fall back to the first (most recently slashed) + // match for the single-candidate case or an amountless invoice — this + // preserves the pre-Phase-6 behaviour exactly. + if matches.len() > 1 { + if let Some(target) = expected_share_sats { + if let Some(exact) = matches.iter().find(|b| { + counterparty_share_sats(b) + .map(|s| s == target) + .unwrap_or(false) + }) { + return Ok(Some(exact.clone())); + } + } + } + Ok(matches.into_iter().next()) } #[cfg(test)] @@ -1624,6 +1726,89 @@ mod tests { assert!(r.is_none()); } + #[test] + fn resolve_payout_recipient_refund_row_pays_the_maker() { + // Phase 6 maker-refund row: `parent_bond_id` set, `child_order_id` + // NULL, `pubkey` = the maker. The recipient is the maker themselves, + // not the trade counterparty. + let order = Order { + kind: Kind::Sell.to_string(), + seller_pubkey: Some(maker_pk().to_string()), + buyer_pubkey: Some(taker_pk().to_string()), + ..Order::default() + }; + let mut refund = pending_payout_bond(Uuid::new_v4(), maker_pk(), 600, 0, 0, None, None); + refund.parent_bond_id = Some(Uuid::new_v4()); + refund.child_order_id = None; + let r = resolve_payout_recipient(&order, &refund, BondSlashReason::LostDispute).unwrap(); + assert_eq!( + r.unwrap().to_string(), + maker_pk(), + "the unslashed-remainder refund is paid back to the maker" + ); + } + + #[test] + fn resolve_payout_recipient_slice_slash_pays_the_winner() { + // Phase 6 slice-slash child: `child_order_id` set, `pubkey` = the + // maker's slice-side key → recipient = the slice's other side (the + // winner), exactly as a normal slash resolves. + let order = Order { + kind: Kind::Sell.to_string(), + seller_pubkey: Some(maker_pk().to_string()), + buyer_pubkey: Some(taker_pk().to_string()), + ..Order::default() + }; + let mut child = pending_payout_bond(Uuid::new_v4(), maker_pk(), 400, 200, 0, None, None); + child.parent_bond_id = Some(Uuid::new_v4()); + child.child_order_id = Some(Uuid::new_v4()); + let r = resolve_payout_recipient(&order, &child, BondSlashReason::LostDispute).unwrap(); + assert_eq!( + r.unwrap().to_string(), + taker_pk(), + "a slice slash pays the non-maker winner" + ); + } + + #[tokio::test] + async fn child_payout_blocked_while_parent_locked() { + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + + let mut parent = + Bond::new_requested(order_id, maker_pk().to_string(), BondRole::Maker, 1_000); + parent.state = BondState::Locked.to_string(); + let parent = create_bond(&pool, parent).await.unwrap(); + + let mut child = pending_payout_bond(order_id, maker_pk(), 400, 0, 0, None, None); + child.parent_bond_id = Some(parent.id); + child.child_order_id = Some(order_id); + let child = create_bond(&pool, child).await.unwrap(); + + // Parent Locked → child must be skipped. + assert!(child_payout_blocked_by_locked_parent(&pool, &child) + .await + .unwrap()); + + // Settle the parent (range close) → child is unblocked. + sqlx::query("UPDATE bonds SET state = ? WHERE id = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + assert!(!child_payout_blocked_by_locked_parent(&pool, &child) + .await + .unwrap()); + + // A normal (non-child) row is never blocked. + let normal = pending_payout_bond(order_id, taker_pk(), 1_000, 0, 0, None, None); + assert!(!child_payout_blocked_by_locked_parent(&pool, &normal) + .await + .unwrap()); + } + #[tokio::test] async fn request_payout_invoice_respects_cadence_window() { // A request issued within `invoice_window_seconds` of the diff --git a/src/app/bond/slash.rs b/src/app/bond/slash.rs index a51ab633..a66c7373 100644 --- a/src/app/bond/slash.rs +++ b/src/app/bond/slash.rs @@ -51,10 +51,14 @@ use mostro_core::message::{Action, BondResolution, Message, Payload}; use mostro_core::order::{Kind, Order, SmallOrder, Status}; use nostr_sdk::prelude::PublicKey; use sqlx::{Pool, Sqlite}; +use sqlx_crud::Crud; use tracing::{info, warn}; use uuid::Uuid; -use super::db::find_active_bonds_for_order; +use super::db::{ + find_active_bonds_for_order, find_child_slashes_for_parent, find_maker_bond_for_order, + find_range_root_order, +}; use super::flow::{ release_bond, release_bonds_for_order_or_warn, release_taker_bonds_for_order_or_warn, }; @@ -100,6 +104,21 @@ pub(super) fn is_already_settled_error(err: &MostroError) -> bool { || s.contains("code=alreadyexists") } +/// Classify a SQLite error as a UNIQUE-constraint violation. sqlx 0.6's +/// `DatabaseError` exposes the extended result code (2067 = +/// `SQLITE_CONSTRAINT_UNIQUE`) and the driver message; either is sufficient. +/// Used so the partial UNIQUE index on `(parent_bond_id, child_order_id)` +/// (migration `20260611120000`) collapses a forced duplicate slice slash into +/// the same idempotent no-op as the `INSERT ... WHERE NOT EXISTS` guard. +pub(super) fn is_unique_violation(err: &sqlx::Error) -> bool { + err.as_database_error().is_some_and(|d| { + d.code().as_deref() == Some("2067") + || d.message() + .to_lowercase() + .contains("unique constraint failed") + }) +} + /// Which trade-flow side a slash flag is targeting. Internal helper — /// callers think in `BondResolution::slash_seller` / `slash_buyer` /// terms. @@ -155,15 +174,68 @@ pub async fn validate_bond_resolution( return Ok(()); } let bonds = find_active_bonds_for_order(pool, order.id).await?; - if resolution.slash_seller && resolve_locked_bond(order, &bonds, Side::Seller).is_none() { + let is_range = order_has_range_maker_bond(pool, order).await?; + if resolution.slash_seller + && resolve_slash_target(pool, order, &bonds, Side::Seller, is_range) + .await? + .is_none() + { return Err(MostroCantDo(CantDoReason::InvalidPayload)); } - if resolution.slash_buyer && resolve_locked_bond(order, &bonds, Side::Buyer).is_none() { + if resolution.slash_buyer + && resolve_slash_target(pool, order, &bonds, Side::Buyer, is_range) + .await? + .is_none() + { return Err(MostroCantDo(CantDoReason::InvalidPayload)); } Ok(()) } +/// Is the bonded party on `side` the **maker** (vs the taker) for this +/// order? §3.1: a `sell` order's maker is the seller; a `buy` order's +/// maker is the buyer. +fn side_is_maker(order: &Order, side: Side) -> Result { + let kind = order.get_order_kind().map_err(MostroInternalErr)?; + Ok(matches!( + (kind, side), + (Kind::Sell, Side::Seller) | (Kind::Buy, Side::Buyer) + )) +} + +/// Resolve the `Locked` bond a slash flag targets, owned so callers don't +/// juggle borrow lifetimes across the range-root fallback. +/// +/// Resolution order: +/// 1. **Pubkey match on this order's active bonds** ([`resolve_locked_bond`] +/// via the §3.1 buyer/seller → trade-pubkey lookup). This covers taker +/// bonds and a non-range / first-slice maker bond (which lives on the +/// order itself). +/// 2. **Range-root fallback** — only for the *maker* side of a range order +/// whose slash landed on a descendant slice (the maker bond lives on the +/// range root, not the slice). Walks `range_parent_id` via +/// [`find_maker_bond_for_order`]. +async fn resolve_slash_target( + pool: &Pool, + order: &Order, + bonds: &[Bond], + side: Side, + is_range: bool, +) -> Result, MostroError> { + if let Some(b) = resolve_locked_bond(order, bonds, side) { + return Ok(Some(b.clone())); + } + if is_range && side_is_maker(order, side)? { + let locked = BondState::Locked.to_string(); + if let Some(b) = find_maker_bond_for_order(pool, order).await? { + if b.state == locked { + return Ok(Some(b)); + } + } + } + Ok(None) +} + /// Apply a validated [`BondResolution`] to every active bond on the order. /// /// For each currently active bond: @@ -200,53 +272,96 @@ pub async fn apply_bond_resolution( resolution: &BondResolution, reason: BondSlashReason, ) -> Result<(), MostroError> { - let bonds = find_active_bonds_for_order(pool, order.id).await?; - if bonds.is_empty() { - return Ok(()); - } - - let mut slashed_ids: HashSet = HashSet::new(); - if resolution.slash_seller { - if let Some(bond) = resolve_locked_bond(order, &bonds, Side::Seller) { - slashed_ids.insert(bond.id); - } - // No-op if a Locked bond is missing: validation should have run - // before any trade-side mutation. Reaching here with a missing - // bond means a concurrent path (release, slash, expiry) raced - // between validate and apply — letting the loop fall through to - // the release branch on whatever remains is the safe outcome. - } - if resolution.slash_buyer { - if let Some(bond) = resolve_locked_bond(order, &bonds, Side::Buyer) { - slashed_ids.insert(bond.id); - } - } + // Active bonds attached to *this* order — i.e. the taker bond(s) on + // this slice. The maker bond may live on a range root elsewhere and is + // resolved separately via `find_maker_bond_for_order`. + let active = find_active_bonds_for_order(pool, order.id).await?; // Snapshot the split percentage *once* per call. Phase 3 will read // `node_share_sats` off each row; we never recompute it after the // transition. let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct); - for bond in bonds.iter() { - if slashed_ids.contains(&bond.id) { - slash_one(pool, ln_client, bond, reason, node_share_pct).await; + // Is the maker bond governing this order a *range* bond (one HTLC sized + // against `max_amount`, slashed proportionally per slice and settled + // only at range close)? If so, the maker bond must never be settled or + // released inline here — `resolve_range_maker_bond_at_close` owns its + // HTLC. We still record a proportional child slash row below. + let is_range = order_has_range_maker_bond(pool, order).await?; + + // Track the bonds we slashed inline (via `slash_one`) so the release + // sweep at the end skips them. Range maker slashes don't go here — the + // parent HTLC stays `Locked` and is excluded from the sweep by the + // `is_range` guard instead. + let mut slashed_ids: HashSet = HashSet::new(); + + for (flag, side) in [ + (resolution.slash_seller, Side::Seller), + (resolution.slash_buyer, Side::Buyer), + ] { + if !flag { + continue; + } + // No-op if no Locked bond resolves: validation should have run + // before any trade-side mutation. Reaching here with a missing bond + // means a concurrent path raced between validate and apply — the + // release sweep below handles whatever remains safely. + let Some(target) = resolve_slash_target(pool, order, &active, side, is_range).await? else { + continue; + }; + if is_range && side_is_maker(order, side)? { + // Phase 6: the maker bond on a range order is slashed + // proportionally per slice — record a child row and leave the + // parent HTLC `Locked`. The single settle happens at range close + // (`resolve_range_maker_bond_at_close`, called by the admin + // handler right after this returns). + let root = find_range_root_order(pool, order.clone()).await?; + record_maker_slice_slash(pool, order, &root, &target, reason, node_share_pct).await?; } else { - // Non-slashed bonds on the same order: release with the - // Phase 1 contract. `release_bond` is best-effort and - // tolerant of transient LND failures. - if let Err(e) = release_bond(pool, bond).await { - warn!( - bond_id = %bond.id, - order_id = %order.id, - "apply_bond_resolution: release_bond failed: {}", e - ); - } + // Taker bond, or a non-range maker bond (Phase 2/5): settle the + // HTLC inline. + slash_one(pool, ln_client, &target, reason, node_share_pct).await; + slashed_ids.insert(target.id); + } + } + + // Release the non-slashed bonds attached to this order. For a range + // order the maker bond is deliberately retained: its HTLC spans the + // whole range and is resolved (settled-at-close or released) by + // `resolve_range_maker_bond_at_close` once the range terminates — the + // admin handler calls that right after this function returns. + let maker_role = BondRole::Maker.to_string(); + for bond in active.iter() { + if slashed_ids.contains(&bond.id) { + continue; + } + if is_range && bond.role == maker_role { + continue; + } + if let Err(e) = release_bond(pool, bond).await { + warn!( + bond_id = %bond.id, + order_id = %order.id, + "apply_bond_resolution: release_bond failed: {}", e + ); } } Ok(()) } +/// True when the maker bond governing `order` is a **range** bond — i.e. +/// the order's range root carries a `max_amount`. Range maker bonds use +/// the Phase 6 accumulate-and-settle-at-close path; everything else uses +/// the Phase 2/5 inline settle. +async fn order_has_range_maker_bond( + pool: &Pool, + order: &Order, +) -> Result { + let root = find_range_root_order(pool, order.clone()).await?; + Ok(root.max_amount.is_some()) +} + /// Phase 4 — timeout-slash dispatch for the scheduler's /// `job_cancel_orders`. /// @@ -624,6 +739,542 @@ async fn slash_one( } } +/// Phase 6 — record a proportional slash of a range maker bond against the +/// taken slice `slice`, **without settling the parent HTLC**. +/// +/// A range maker posts a single hold invoice (the `parent_bond`) sized +/// against `max_amount`. A BOLT11 hold invoice is all-or-nothing, so we +/// cannot claim only one slice's share mid-range. Instead we insert a child +/// row recording the share and leave the parent `Locked`; the actual settle +/// happens once, at range close +/// ([`resolve_range_maker_bond_at_close`], Option A / "accumulate and +/// settle-at-close"). +/// +/// The share is **price-invariant**: `slice.fiat_amount / root.max_amount` +/// (both fiat — the ratio is independent of any BTC price drift between +/// publication and take), times the locked bond amount. The child row's +/// `order_id` and maker-side `pubkey` are the slice's, so the Phase 3 +/// recipient resolver pays the slice's *other* side (the winner). +async fn record_maker_slice_slash( + pool: &Pool, + slice: &Order, + root: &Order, + parent_bond: &Bond, + reason: BondSlashReason, + node_share_pct: f64, +) -> Result<(), MostroError> { + let kind = slice.get_order_kind().map_err(MostroInternalErr)?; + let maker_slice_pubkey = match kind { + Kind::Sell => slice.seller_pubkey.as_deref(), + Kind::Buy => slice.buyer_pubkey.as_deref(), + }; + let Some(maker_slice_pubkey) = maker_slice_pubkey else { + warn!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice has no maker-side pubkey; skipping" + ); + return Ok(()); + }; + let Some(max_fiat) = root.max_amount.filter(|m| *m > 0) else { + warn!( + bond_id = %parent_bond.id, + root_order_id = %root.id, + "record_maker_slice_slash: range root missing positive max_amount; skipping" + ); + return Ok(()); + }; + + // Price-invariant proportional share, clamped so the cumulative slashed + // share can never exceed the locked bond (rounding guard). + let raw = (parent_bond.amount_sats as f64 * slice.fiat_amount as f64 / max_fiat as f64).round() + as i64; + let remaining = (parent_bond.amount_sats - parent_bond.slashed_share_sats).max(0); + let slash_amount = raw.clamp(0, remaining); + if slash_amount <= 0 { + warn!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + raw, remaining, + "record_maker_slice_slash: computed non-positive / over-allocated share; skipping" + ); + return Ok(()); + } + + let now = Utc::now().timestamp(); + let node_share = compute_node_share(slash_amount, node_share_pct); + + // Insert the child slash row **atomically** with two guards, so a slice is + // slashed at most once under a given parent *and* only while that parent is + // still open. This must be a single statement, not a read-then-`create_bond`: + // 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, so two concurrent duplicate cancels could + // otherwise both pass a separate existence check and both allocate against + // the same (single) HTLC. + // + // The guards: + // * `NOT EXISTS (child for this slice)` — at-most-once per slice. + // * `EXISTS (parent still Locked)` — lock the child out once the parent + // close wins its `Locked → Slashed` CAS (see + // `resolve_range_maker_bond_at_close`). Without this a slash that lands + // after the close already settled + refunded the HTLC would insert a + // `PendingPayout` child that the scheduler pays out, pushing the total + // distributed past the single settled HTLC. A slice that misses this + // window is dropped (logged below): the safe direction, since the HTLC + // amount is already fixed. + // + // Both run under SQLite's single write lock, so they observe the same + // committed parent state as the close CAS; the loser sees `rows_affected = 0`. + // `order_id` is the slice's and there is no `preimage`/`hash`/`payment_request` + // — the child shares the parent HTLC. Unset columns take their schema + // defaults (`slashed_share_sats`/`payout_attempts`/`invoice_request_attempts` + // = 0, the rest NULL), matching `Bond::new_requested`. + let insert = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \ + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?) \ + AND EXISTS ( \ + SELECT 1 FROM bonds WHERE id = ? AND state = ?)", + ) + .bind(Uuid::new_v4()) + .bind(slice.id) + .bind(parent_bond.id) + .bind(slice.id) + .bind(maker_slice_pubkey) + .bind(BondRole::Maker.to_string()) + .bind(slash_amount) + .bind(BondState::PendingPayout.to_string()) + .bind(reason.to_string()) + .bind(node_share) + .bind(now) + .bind(now) + .bind(parent_bond.id) + .bind(slice.id) + .bind(parent_bond.id) + .bind(BondState::Locked.to_string()) + .execute(pool) + .await; + // The `WHERE NOT EXISTS` guard makes the loser of a race insert 0 rows. + // The partial UNIQUE index on `(parent_bond_id, child_order_id)` + // (migration `20260611120000`) is defence-in-depth for any future caller + // that forgets the guard: treat a constraint violation as the same + // idempotent no-op, never an error. + let inserted = match insert { + Ok(r) => r, + Err(e) if is_unique_violation(&e) => { + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice already slashed (unique index); skipping duplicate" + ); + return Ok(()); + } + Err(e) => { + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))) + } + }; + if inserted.rows_affected() == 0 { + // Either the slice was already slashed (duplicate) or the parent bond + // is no longer `Locked` — its close already settled the HTLC, so this + // slice missed the slash window and is intentionally dropped. + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice already slashed or parent no longer Locked; skipping" + ); + return Ok(()); + } + + // Recompute the parent's running total from the authoritative slice + // child rows (self-healing: a crash between the insert above and this + // update is repaired by the next slash or by the close recompute). Only + // touch a still-`Locked` parent. The refund row (`child_order_id NULL`) + // is excluded so it never inflates the slashed total. + sqlx::query( + "UPDATE bonds SET slashed_share_sats = \ + (SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL) \ + WHERE id = ? AND state = ?", + ) + .bind(parent_bond.id) + .bind(parent_bond.id) + .bind(BondState::Locked.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + reason = %reason, + slash_amount, + "Phase 6: recorded proportional maker slice slash (parent HTLC stays Locked)" + ); + Ok(()) +} + +/// Phase 6 — resolve a maker bond when its order (range chain) terminates +/// (settle-at-close, "Option A"). +/// +/// Called from every terminal hook for an order *except* a successful +/// release that spawns a range remainder (the range continues then, so the +/// maker stays committed). Idempotent and best-effort: +/// +/// - **Non-range maker bond** → released inline (`cancel_hold_invoice`). +/// Unifies the Phase 5 completion/cancel behaviour so hooks call one +/// function for both fixed and range makers. +/// - **Range, no slice ever slashed** → released: the maker gets the whole +/// bond back (the HTLC is cancelled, never charged). +/// - **Range, ≥1 slice slashed** → the single parent HTLC is settled +/// **once** (claiming the full bond into Mostro's wallet), the parent row +/// moves `Locked → Slashed`, and an unslashed-remainder **refund row** +/// (`child_order_id = NULL`, recipient = the maker) is created. The +/// per-slice child rows and the refund row — all `PendingPayout` — are +/// then driven by the Phase 3 payout scheduler, which skipped them while +/// the parent was `Locked`. +pub async fn resolve_range_maker_bond_at_close( + pool: &Pool, + ln_client: &mut L, + order: &Order, +) -> Result<(), MostroError> { + let Some(parent) = find_maker_bond_for_order(pool, order).await? else { + return Ok(()); + }; + // Idempotent: act only on a still-`Locked` parent. A prior close (or a + // Phase 5 inline release/slash) already moved it on. + if parent.state != BondState::Locked.to_string() { + return Ok(()); + } + + let root = find_range_root_order(pool, order.clone()).await?; + let slice_children: Vec = if root.max_amount.is_some() { + find_child_slashes_for_parent(pool, parent.id) + .await? + .into_iter() + .filter(|c| c.child_order_id.is_some()) + .collect() + } else { + // Non-range maker bond: no child rows exist; fall through to release. + Vec::new() + }; + + // Snapshot total, used only as a fast-path hint to choose release vs. + // settle. The refund below is recomputed authoritatively *inside* the + // close transaction — this read can be stale (a concurrent slice slash may + // commit between here and the CAS). + let snapshot_slashed: i64 = slice_children.iter().map(|c| c.amount_sats).sum(); + if snapshot_slashed == 0 { + // Nothing was ever slashed across the whole range (or non-range + // happy path): release the bond back to the maker. + return release_bond(pool, &parent).await; + } + + // ≥1 slice slashed: settle the whole HTLC once. Settle BEFORE the CAS so + // a transient failure leaves the parent retryably `Locked`. + let Some(preimage) = parent.preimage.as_deref() else { + warn!( + bond_id = %parent.id, + "range close: parent bond has no preimage; cannot settle — left Locked" + ); + return Ok(()); + }; + if let Err(e) = ln_client.settle_hold_invoice(preimage).await { + if is_already_settled_error(&e) { + info!( + bond_id = %parent.id, + "range close: parent HTLC already settled (idempotent); proceeding" + ); + } else { + warn!( + bond_id = %parent.id, + "range close: settle_hold_invoice failed: {e} — leaving Locked for retry" + ); + return Ok(()); + } + } + + // The HTLC is now settled while the parent is still `Locked`. Perform ALL + // the DB-side close work — the CAS, the child claim-window re-anchor, and + // the maker-refund row — in ONE transaction, so `Slashed` only ever + // becomes visible once the dependent rows are durably written together. + // A crash anywhere in here leaves a `Locked` parent that the + // reconciliation sweep retries; the retry re-settles harmlessly (LND + // returns "already settled", classified as success above). `Locked` is + // thus the sole in-flight state and there is no partially-closed `Slashed` + // window to repair. + let now = Utc::now().timestamp(); + + let mut tx = pool + .begin() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Move the parent `Locked → Slashed`. The CAS — kept inside the tx — + // ensures exactly one close wins if two terminal hooks race; the loser + // sees `rows_affected = 0`, rolls back, and returns with no side effects + // (so it never writes a second refund row). + let cas = sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .bind(BondState::Locked.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + if cas.rows_affected() != 1 { + let _ = tx.rollback().await; + info!( + bond_id = %parent.id, + "range close: parent already closed concurrently; skipping refund row" + ); + return Ok(()); + } + + // Recompute the slashed total authoritatively from the child rows *inside* + // the transaction. The CAS above now holds the write lock and the parent is + // `Slashed`, so the gated slice-slash INSERT (see `record_maker_slice_slash`) + // can no longer add a child: this SUM is the final, consistent set. Deriving + // the refund from the pre-transaction `snapshot_slashed` instead would + // over-refund the maker whenever a slice slash committed between the snapshot + // read and the CAS — the late child would still be paid out, pushing the + // total distributed (children + refund) past the single settled HTLC. + let total_slashed: i64 = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(parent.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + let refund_amount = (parent.amount_sats - total_slashed).max(0); + + // Phase 6: anchor each slice child's claim window at *close* time, not at + // slice-slash time. A child can only be paid out once the parent HTLC is + // settled (now); leaving its `slashed_at` at slice-slash time would let + // the `payout_claim_window_days` countdown run while the bond was still + // unpayable, so a range that stayed open past the window could forfeit a + // child the instant it became processable, before the counterparty was + // ever asked for an invoice. (In the dispute path close follows the slash + // almost immediately, but the timeout-slash path in Phase 7 may not.) + if !slice_children.is_empty() { + sqlx::query( + "UPDATE bonds SET slashed_at = ? \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL AND state = ?", + ) + .bind(now) + .bind(parent.id) + .bind(BondState::PendingPayout.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + + if refund_amount > 0 { + // Inherit a parseable reason from a slice child so the Phase 3 + // `slashed_reason` invariant holds; the refund recipient is resolved + // directly from the row (the maker), not via the reason. Raw INSERT + // (mirroring the slice-slash insert, `child_order_id = NULL` marks the + // maker-refund row) so it runs on the same transaction as the CAS. + // Unset columns take their schema defaults, matching `Bond::new_requested`. + let reason = slice_children + .first() + .and_then(|c| c.slashed_reason.clone()) + .unwrap_or_else(|| BondSlashReason::LostDispute.to_string()); + sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \ + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(parent.pubkey.clone()) + .bind(BondRole::Maker.to_string()) + .bind(refund_amount) + .bind(BondState::PendingPayout.to_string()) + .bind(reason) + .bind(0_i64) // node_share_sats = 0: full refund to the maker + .bind(now) + .bind(now) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + + tx.commit() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + info!( + bond_id = %parent.id, + order_id = %root.id, + amount_sats = parent.amount_sats, + total_slashed, + refund_amount, + children = slice_children.len(), + "Phase 6: range maker bond settled at close; distributing child shares + maker refund" + ); + + Ok(()) +} + +/// Open an `LndConnector` and run [`resolve_range_maker_bond_at_close`], +/// logging on failure. For the non-admin terminal hooks (completion, +/// cancel, scheduler expiry) that don't already hold an LND client. A cheap +/// pre-check skips opening LND entirely when there is no `Locked` maker bond +/// to resolve — the common case. The admin handlers pass their own +/// `ln_client` to the generic function directly. +pub async fn resolve_range_maker_bond_at_close_or_warn( + pool: &Pool, + order: &Order, + context: &'static str, +) { + let locked = BondState::Locked.to_string(); + match find_maker_bond_for_order(pool, order).await { + Ok(Some(b)) if b.state == locked => {} + Ok(_) => return, + Err(e) => { + warn!("{context}: maker bond lookup failed for {}: {e}", order.id); + return; + } + } + let mut ln = match LndConnector::new().await { + Ok(l) => l, + Err(e) => { + warn!("{context}: cannot connect to LND to resolve maker bond at close: {e}"); + return; + } + }; + if let Err(e) = resolve_range_maker_bond_at_close(pool, &mut ln, order).await { + warn!( + order_id = %order.id, + "{context}: resolve_range_maker_bond_at_close failed: {e}" + ); + } +} + +/// Collect the range-root orders whose maker bond is still `Locked` even +/// though the whole range tree has terminated — the stranded set the +/// reconciliation sweep retries. +/// +/// `resolve_range_maker_bond_at_close[_or_warn]` is best-effort (§8.2): on a +/// transient LND/DB failure it logs and leaves the parent `Locked`, and once +/// the order is terminal nothing re-invokes it, so the HTLC would sit +/// `Locked` until the LND CLTV safety net (and any slashed slice's payout +/// stays blocked the whole time). This is the scan side of the periodic +/// retry: a parent maker bond is "stranded" when it is still `Locked` and +/// every order in its range tree (root + every `range_parent_id` descendant) +/// is in a terminal status, so a legitimately-open range — whose maker bond +/// is `Locked` by design — is never touched. +async fn collect_stranded_range_maker_roots( + pool: &Pool, +) -> Result, MostroError> { + let mut stranded = Vec::new(); + for bond in super::db::find_locked_maker_parent_bonds(pool).await? { + // Best-effort, per-root isolation (§8.2): a transient failure on one + // root (DB busy, a missing/corrupt order row) must only skip that + // root, never abort the whole tick and starve retries for every other + // stranded bond. So per-root errors log and `continue` rather than + // propagate via `?`. + // + // The parent maker bond lives on the range root, so `bond.order_id` + // is the root id. + let root = match Order::by_id(pool, bond.order_id).await { + Ok(Some(root)) => root, + Ok(None) => continue, // missing order row (should never happen) + Err(e) => { + warn!( + order_id = %bond.order_id, + error = %e, + "reconcile_sweep: range-root order lookup failed; skipping this root" + ); + continue; + } + }; + match super::db::range_tree_fully_terminal(pool, bond.order_id).await { + Ok(true) => stranded.push(root), + Ok(false) => {} + Err(e) => { + warn!( + order_id = %bond.order_id, + error = %e, + "reconcile_sweep: range-tree terminal check failed; skipping this root" + ); + continue; + } + } + } + Ok(stranded) +} + +/// Reconciliation sweep (testable core): retry the settle-at-close for every +/// stranded range maker bond, returning how many resolved without error. +/// +/// `resolve_range_maker_bond_at_close` is idempotent (a CAS `Locked → +/// Slashed`), so re-invoking it is safe whether the prior close half-finished +/// or never ran. A per-bond failure is logged and the sweep moves on — the +/// next tick (and ultimately the CLTV safety net) retries. +pub(crate) async fn reconcile_stranded_range_maker_bonds_with( + pool: &Pool, + ln_client: &mut L, +) -> usize { + let roots = match collect_stranded_range_maker_roots(pool).await { + Ok(r) => r, + Err(e) => { + warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}"); + return 0; + } + }; + let mut resolved = 0; + for order in &roots { + match resolve_range_maker_bond_at_close(pool, ln_client, order).await { + Ok(()) => resolved += 1, + Err(e) => warn!( + order_id = %order.id, + "reconcile_sweep: resolve_range_maker_bond_at_close failed: {e}" + ), + } + } + resolved +} + +/// Reconciliation sweep (scheduler entry point): scan for range maker bonds +/// stranded `Locked` after a failed close and retry each one. Opens a single +/// `LndConnector` for the batch, and only when there is at least one stranded +/// bond — the common case (no stranded bond) costs one indexed query and +/// never touches LND. +pub async fn reconcile_stranded_range_maker_bonds(pool: &Pool) { + // Cheap pre-check so an idle node never opens LND just to find nothing. + match collect_stranded_range_maker_roots(pool).await { + Ok(roots) if roots.is_empty() => return, + Ok(_) => {} + Err(e) => { + warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}"); + return; + } + } + let mut ln = match LndConnector::new().await { + Ok(l) => l, + Err(e) => { + warn!("reconcile_sweep: cannot connect to LND to retry stranded maker bonds: {e}"); + return; + } + }; + let resolved = reconcile_stranded_range_maker_bonds_with(pool, &mut ln).await; + if resolved > 0 { + info!( + resolved, + "reconcile_sweep: retried stranded range maker bond(s) at close" + ); + } +} + /// Resolve a buyer/seller slash flag to the matching `Locked` bond row, /// if any. The mapping uses the §3.1 buyer-side → trade-pubkey lookup /// on the order, then filters bonds by `pubkey` and `state = Locked`. @@ -734,6 +1385,25 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); + // Phase 6 chain-walk tests load full `Order` rows via `Order::by_id`, + // which selects every column the model declares — so the orders + // table must carry the later Cashu columns too. + for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--"))) + { + sqlx::query(stmt) + .execute(&pool) + .await + .expect("cashu_escrow_fields migration"); + } + sqlx::query(include_str!( + "../../../migrations/20260611120000_bond_slice_slash_unique.sql" + )) + .execute(&pool) + .await + .expect("bond_slice_slash_unique migration"); pool } @@ -1921,4 +2591,893 @@ mod tests { "BondSlashed must be enqueued to the slashed taker only" ); } + + // ── Phase 6: range-order maker bond ──────────────────────────────── + + use crate::app::bond::db::{ + find_bond_by_id, find_child_slashes_for_parent, find_maker_bond_for_order, + find_range_root_order, + }; + + /// Insert an order row including the range columns (`min_amount`, + /// `max_amount`, `range_parent_id`) that `insert_order_row` omits. + async fn insert_range_order_row(pool: &Pool, order: &Order) { + sqlx::query( + r#"INSERT INTO orders ( + id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, min_amount, max_amount, + range_parent_id, created_at, expires_at, seller_pubkey, buyer_pubkey + ) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(order.id) + .bind(&order.kind) + .bind(order.id.simple().to_string()) + .bind(&order.status) + .bind(&order.payment_method) + .bind(order.amount) + .bind(&order.fiat_code) + .bind(order.fiat_amount) + .bind(order.min_amount) + .bind(order.max_amount) + .bind(order.range_parent_id) + .bind(order.created_at) + .bind(order.expires_at) + .bind(order.seller_pubkey.as_deref()) + .bind(order.buyer_pubkey.as_deref()) + .execute(pool) + .await + .expect("insert range order"); + } + + /// A range-order slice: `amount = 0`, `min`/`max` set (so + /// `is_range_order()` and the root-`max_amount` check hold). + fn range_slice( + kind: Kind, + seller_pk: &str, + buyer_pk: &str, + fiat_amount: i64, + min: i64, + max: i64, + ) -> Order { + let mut o = fixture_order(kind, seller_pk, buyer_pk); + o.amount = 0; + o.fiat_amount = fiat_amount; + o.min_amount = Some(min); + o.max_amount = Some(max); + o + } + + /// A `Locked` parent maker bond with a settleable preimage and **no** + /// `hash` (so the release path skips the live LND `cancel_hold_invoice` + /// and the settle path is exercised only via the `StubSettle`). + async fn insert_parent_maker_bond( + pool: &Pool, + order_id: Uuid, + pubkey: &str, + amount_sats: i64, + ) -> Bond { + let mut b = Bond::new_requested(order_id, pubkey.to_string(), BondRole::Maker, amount_sats); + b.state = BondState::Locked.to_string(); + b.preimage = Some(stub_preimage()); + b.hash = None; + sqlx_crud::Crud::create(b.clone(), pool).await.unwrap(); + b + } + + #[tokio::test] + async fn record_maker_slice_slash_is_proportional() { + // sell range order: maker = seller. max fiat = 100, slice fiat = 40, + // bond = 1000 → slash = round(1000 * 40/100) = 400; node 50% = 200. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1); + let c = &children[0]; + assert_eq!(c.amount_sats, 400, "proportional slice share"); + assert_eq!(c.node_share_sats, Some(200)); + assert_eq!(c.state, BondState::PendingPayout.to_string()); + assert_eq!(c.parent_bond_id, Some(parent.id)); + assert_eq!(c.child_order_id, Some(root.id)); + assert_eq!(c.order_id, root.id); + // Maker's slice-side (seller) key, so Phase 3 pays the buyer winner. + assert_eq!(c.pubkey, maker_pk()); + assert!(c.slashed_at.is_some()); + assert!(c.preimage.is_none(), "child shares the parent HTLC"); + + // Parent accumulates the running total but stays Locked (no settle). + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.slashed_share_sats, 400); + assert_eq!(p.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn record_maker_slice_slash_clamps_cumulative_to_bond() { + // Two slices that together exceed the bond must clamp so the + // cumulative slashed share never exceeds `amount_sats`. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // First slice 80/100 → 800. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + // Reload parent (slashed_share_sats now 800) and slash a *second*, + // distinct slice (its own order row) of another 80/100 → raw 800, but + // only 200 remaining → clamp to 200. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100); + insert_range_order_row(&pool, &slice2).await; + record_maker_slice_slash( + &pool, + &slice2, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + let total: i64 = children.iter().map(|c| c.amount_sats).sum(); + assert_eq!(total, 1000, "cumulative slash clamped to the bond amount"); + } + + #[tokio::test] + async fn range_close_no_slashes_releases_maker_bond() { + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let stub = StubSettle::new(); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + assert!( + stub.calls().is_empty(), + "no slice was ever slashed → the HTLC must be cancelled, not settled" + ); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Released.to_string()); + assert!(find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn range_close_with_one_slash_settles_once_and_refunds_remainder() { + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + // Settled exactly once, with the parent preimage. + assert_eq!(stub.calls(), vec![stub_preimage()]); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "slice slash + maker refund row"); + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("a maker-refund row"); + assert_eq!(refund.amount_sats, 600, "1000 bond - 400 slashed"); + assert_eq!(refund.node_share_sats, Some(0), "full refund to the maker"); + assert_eq!(refund.pubkey, maker_pk()); + assert_eq!(refund.order_id, root.id); + assert_eq!(refund.state, BondState::PendingPayout.to_string()); + + // Idempotent: a second close (parent now Slashed) is a no-op. + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + assert_eq!(stub.calls().len(), 1, "no second settle"); + assert_eq!( + find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .len(), + 2, + "no duplicate refund row" + ); + } + + #[tokio::test] + async fn range_close_crash_after_settle_is_resumed() { + // The settle precedes one atomic state+rows transaction. If the HTLC + // settles but the transaction fails (crash / DB error before commit), + // the parent must stay `Locked` with NO refund row and NO re-anchor — + // so the Locked-keyed reconciliation sweep covers the window — and a + // later retry (mock reports "already settled") completes the close. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + // Snapshot the slice child's pre-close slashed_at to prove it is only + // re-anchored on a committed close. + let slice_before = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .into_iter() + .find(|c| c.child_order_id.is_some()) + .expect("slice child"); + let original_slashed_at = slice_before.slashed_at; + + // Inject a DB failure mid-transaction: a trigger that aborts the + // maker-refund INSERT (the row with parent_bond_id set, child_order_id + // NULL). The slice-slash insert and the CAS/UPDATE are unaffected. + sqlx::query( + "CREATE TRIGGER bond_refund_fail BEFORE INSERT ON bonds \ + WHEN NEW.parent_bond_id IS NOT NULL AND NEW.child_order_id IS NULL \ + BEGIN SELECT RAISE(ABORT, 'injected refund insert failure'); END", + ) + .execute(&pool) + .await + .unwrap(); + + // Settle succeeds, then the transaction aborts → the whole close errors. + let stub = StubSettle::new(); + let err = resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root).await; + assert!( + err.is_err(), + "a failed close transaction must surface as Err" + ); + assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once"); + + // Parent rolled back to Locked; no refund row; slice child NOT re-anchored. + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Locked.to_string(), + "parent must remain Locked so the sweep retries it" + ); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "no refund row was written"); + assert!(children.iter().all(|c| c.child_order_id.is_some())); + let slice_mid = children + .iter() + .find(|c| c.child_order_id.is_some()) + .unwrap(); + assert_eq!( + slice_mid.slashed_at, original_slashed_at, + "the slice claim window must not re-anchor until the close commits" + ); + + // Remove the injected failure and re-run the close (the sweep's retry). + // The HTLC is already settled, so LND reports "already settled". + sqlx::query("DROP TRIGGER bond_refund_fail") + .execute(&pool) + .await + .unwrap(); + // Backdate the slice child so the close-time re-anchor is observable + // despite 1-second timestamp granularity. + sqlx::query( + "UPDATE bonds SET slashed_at = 1000000 \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + let before_close = Utc::now().timestamp(); + stub.fail_next_with("invoice already settled"); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + // Now fully closed: settle attempted twice, but rows written once. + assert_eq!(stub.calls().len(), 2, "settle re-attempted on retry"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "exactly one refund row after the retry"); + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.amount_sats, 600); + assert_eq!(refund.node_share_sats, Some(0)); + let slice_after = children + .iter() + .find(|c| c.child_order_id.is_some()) + .unwrap(); + assert!( + slice_after.slashed_at.unwrap() >= before_close, + "slice claim window re-anchored at close time once committed (got {:?})", + slice_after.slashed_at + ); + } + + #[tokio::test] + async fn settle_already_settled_is_treated_as_success() { + // A double-settle (LND returns "already settled") is classified as + // success, so a close after a crashed settle still completes normally. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + stub.fail_next_with("invoice already settled"); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + let refund = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .into_iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.amount_sats, 600); + } + + #[tokio::test] + async fn apply_range_maker_slash_records_child_without_settling() { + let pool = setup_pool().await; + // sell range order: `slash_seller` targets the maker (seller). + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let stub = StubSettle::new(); + apply_bond_resolution( + &pool, + &mut stub.clone(), + &root, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert!( + stub.calls().is_empty(), + "a range maker slash records a child row but must NOT settle inline" + ); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Locked.to_string()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0].amount_sats, 400); + assert_eq!(children[0].child_order_id, Some(root.id)); + } + + #[tokio::test] + async fn maker_bond_resolves_from_descendant_slice() { + // The maker bond lives on the range root; a slash on a descendant + // slice must still find it by walking `range_parent_id`. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 30, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let mut c1 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 70); + c1.range_parent_id = Some(root.id); + insert_range_order_row(&pool, &c1).await; + + let found = find_maker_bond_for_order(&pool, &c1) + .await + .unwrap() + .expect("maker bond resolved via the range root"); + assert_eq!(found.id, parent.id); + + let resolved_root = find_range_root_order(&pool, c1.clone()).await.unwrap(); + assert_eq!(resolved_root.id, root.id); + } + + #[tokio::test] + async fn range_close_reanchors_slice_child_claim_window() { + // A child's payout claim window must start at *close* time, not at + // slice-slash time — otherwise a long-open range could forfeit the + // child the instant it becomes payable. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // Backdate the slice child's slashed_at to simulate a range that + // stayed open well past the claim window before closing. + sqlx::query( + "UPDATE bonds SET slashed_at = ? WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(1_000_000i64) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + + let before_close = Utc::now().timestamp(); + resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + let slice = children + .iter() + .find(|c| c.child_order_id.is_some()) + .expect("slice child"); + assert!( + slice.slashed_at.unwrap() >= before_close, + "slice child claim window must re-anchor at close time, got {:?}", + slice.slashed_at + ); + } + + #[tokio::test] + async fn record_maker_slice_slash_is_idempotent_per_slice() { + // Recording the same slice twice (e.g. a retry while the parent HTLC + // is still Locked) must NOT insert a second child row or double the + // accumulated share. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + // Reload parent (slashed_share_sats now 400) and replay the slash. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "the slice must be slashed exactly once"); + assert_eq!(children[0].amount_sats, 400); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.slashed_share_sats, 400, "share must not double on replay"); + } + + #[tokio::test] + async fn record_maker_slice_slash_skipped_once_parent_left_locked() { + // Race guard: once the parent close wins its `Locked → Slashed` CAS and + // settles + refunds the single HTLC, a slice slash that lands afterwards + // must NOT insert a child row — otherwise the scheduler would pay that + // orphan out on top of the already-distributed HTLC. The INSERT's + // `EXISTS (parent still Locked)` guard makes it a no-op (rows_affected = + // 0), independent of the per-slice uniqueness guard. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // Simulate the close having already moved the parent off `Locked`. + sqlx::query("UPDATE bonds SET state = ? WHERE id = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + + // A slice slash arriving after the close is dropped silently. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert!( + children.is_empty(), + "no child row may be inserted once the parent has left Locked" + ); + } + + #[tokio::test] + async fn slice_slash_unique_index_rejects_forced_duplicate() { + // Item 1: the partial UNIQUE index on (parent_bond_id, child_order_id) + // enforces "one slash row per slice" at the schema level, even for a + // caller that bypasses the `INSERT ... WHERE NOT EXISTS` guard. A + // forced raw duplicate insert must fail with a unique violation. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // First child row via the normal path. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // A raw insert of a second child row for the same (parent, child) — + // no `WHERE NOT EXISTS` guard — must be rejected by the index. + let now = Utc::now().timestamp(); + let forced = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(root.id) + .bind(maker_pk()) + .bind(BondRole::Maker.to_string()) + .bind(123) + .bind(BondState::PendingPayout.to_string()) + .bind(now) + .execute(&pool) + .await; + let err = forced.expect_err("duplicate child slash must violate the unique index"); + assert!( + is_unique_violation(&err), + "expected a unique-constraint violation, got {err:?}" + ); + + // The maker-refund shape (child_order_id NULL) and parent rows are + // unconstrained: SQLite treats NULLs as distinct, so this succeeds. + let refund = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, created_at) \ + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(maker_pk()) + .bind(BondRole::Maker.to_string()) + .bind(456) + .bind(BondState::PendingPayout.to_string()) + .bind(now) + .execute(&pool) + .await; + assert!( + refund.is_ok(), + "a maker-refund row (child_order_id NULL) must be unconstrained: {refund:?}" + ); + } + + #[tokio::test] + async fn reconcile_sweep_retries_stranded_locked_parent_after_failed_close() { + // Item 2: a transient settle failure on the first close leaves the + // parent `Locked` even though the range is terminal. The + // reconciliation sweep must retry and drive it to `Slashed`, unblocking + // the slice child rows. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + // Whole range terminated (cooperatively canceled). + root.status = Status::CooperativelyCanceled.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // First close: settle fails transiently → parent stays Locked. + let failing = StubSettle::new(); + failing.fail_next_with("transient lnd transport error"); + resolve_range_maker_bond_at_close(&pool, &mut failing.clone(), &root) + .await + .unwrap(); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Locked.to_string(), + "a failed settle must leave the parent retryably Locked" + ); + + // The sweep finds the stranded parent (range tree fully terminal) and + // retries the close with a healthy LND stub. + let healthy = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut healthy.clone()).await; + assert_eq!(resolved, 1, "exactly one stranded parent resolved"); + assert_eq!(healthy.calls(), vec![stub_preimage()], "HTLC settled once"); + + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Slashed.to_string(), + "the sweep drives the parent to Slashed" + ); + // The slice child is now unblocked (parent no longer Locked) and a + // maker-refund row exists. + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "slice slash + maker refund row"); + assert!(children + .iter() + .all(|c| c.state == BondState::PendingPayout.to_string())); + } + + #[tokio::test] + async fn reconcile_sweep_skips_open_range() { + // The sweep must never disturb a legitimately-open range whose maker + // bond is `Locked` by design (a slice can still be taken). + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::Active.to_string(); // still on the book + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let stub = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await; + assert_eq!(resolved, 0, "an open range must not be swept"); + assert!(stub.calls().is_empty(), "no HTLC touched"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn reconcile_sweep_isolates_a_bad_root_and_processes_the_rest() { + // Per-root isolation (§8.2): a `Locked` maker parent whose range-root + // order can't be resolved (here: a missing order row → `Order::by_id` + // returns None) must only skip that root, never abort the tick and + // starve every other stranded bond. The tree-check error path shares + // the same `continue`. + let pool = setup_pool().await; + + // Bad root: a Locked maker parent bond whose order_id has no order row + // (a corrupt/partially-deleted state). FK enforcement is on by + // default, so drop it just for this orphan insert to simulate that. + let orphan_order_id = Uuid::new_v4(); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&pool) + .await + .unwrap(); + let bad = insert_parent_maker_bond(&pool, orphan_order_id, maker_pk(), 1000).await; + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await + .unwrap(); + + // Good root: a fully-terminal range with one slashed slice — stranded + // and genuinely resolvable. + let mut good = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + good.status = Status::CooperativelyCanceled.to_string(); + insert_range_order_row(&pool, &good).await; + let good_parent = insert_parent_maker_bond(&pool, good.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &good, + &good, + &good_parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await; + + // The good root was still processed despite the bad root. + assert_eq!(resolved, 1, "the valid stranded root must still resolve"); + let gp = find_bond_by_id(&pool, good_parent.id) + .await + .unwrap() + .unwrap(); + assert_eq!(gp.state, BondState::Slashed.to_string()); + // The bad root's bond is untouched (still Locked, never settled). + let bp = find_bond_by_id(&pool, bad.id).await.unwrap().unwrap(); + assert_eq!(bp.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn range_close_conserves_sats_across_two_slices() { + // Item 3: wallet-accounting invariant. With 2 distinct slices slashed + // (exercising accumulation + rounding), the sum of every child row's + // amount_sats (slice slashes + maker refund) equals the parent bond + // exactly, and node retention (Σ node_share_sats) + counterparty + // payouts (Σ amount - node_share) == the parent bond. The maker refund + // absorbs any rounding remainder by construction (refund = bond - + // Σ slice_slashes), so no sat is created or lost. + let pool = setup_pool().await; + // max fiat 7, bond 1000: slice fiat 2 → round(1000*2/7)=286, + // slice fiat 3 → round(1000*3/7)=429. Both round (285.71 / 428.57), + // so the refund (1000-715=285) must absorb the remainder. + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 2, 1, 7); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let node_pct = 0.3; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + node_pct, + ) + .await + .unwrap(); + // Second, distinct slice (own order row); reload parent for the + // updated running total. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 3, 1, 7); + insert_range_order_row(&pool, &slice2).await; + record_maker_slice_slash( + &pool, + &slice2, + &root, + &parent, + BondSlashReason::LostDispute, + node_pct, + ) + .await + .unwrap(); + + resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root) + .await + .unwrap(); + + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + // 2 slice slashes + 1 maker refund. + assert_eq!(children.len(), 3, "two slices + maker refund"); + + let total_child_sats: i64 = children.iter().map(|c| c.amount_sats).sum(); + assert_eq!( + total_child_sats, parent.amount_sats, + "Σ child amount_sats (slices + refund) must equal the parent bond exactly" + ); + + let node_retention: i64 = children + .iter() + .map(|c| c.node_share_sats.unwrap_or(0)) + .sum(); + let counterparty_total: i64 = children + .iter() + .map(|c| c.amount_sats - c.node_share_sats.unwrap_or(0)) + .sum(); + assert_eq!( + node_retention + counterparty_total, + parent.amount_sats, + "no sat created or lost: node retention + counterparty payouts == bond" + ); + // The refund row carries the unslashed remainder (bond minus the two + // slice slashes), full value to the maker — this is where the rounding + // remainder lands. + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.node_share_sats, Some(0)); + let slice_slash_total: i64 = children + .iter() + .filter(|c| c.child_order_id.is_some()) + .map(|c| c.amount_sats) + .sum(); + assert_eq!( + refund.amount_sats, + parent.amount_sats - slice_slash_total, + "refund = bond - Σ slice slashes (absorbs the rounding remainder)" + ); + } } diff --git a/src/app/cancel.rs b/src/app/cancel.rs index fe0ba83b..f79c4f36 100644 --- a/src/app/cancel.rs +++ b/src/app/cancel.rs @@ -146,9 +146,12 @@ async fn cancel_cooperative_execution_step_2( ) .await; - // Phase 1: cooperative cancel always releases any taker bond. The - // dispute slash path lands in Phase 2. - bond::release_bonds_for_order_or_warn(pool, order.id, "cooperative_cancel").await; + // Phase 1/6: cooperative cancel releases any taker bond and resolves + // the maker bond at range close (Phase 6 settle-at-close if earlier + // slices were slashed, else release; the close helper also covers the + // non-range maker bond via its non-range branch). + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "cooperative_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "cooperative_cancel").await; Ok(()) } @@ -374,9 +377,12 @@ async fn cancel_order_by_maker( ) .await; - // Phase 1: maker cancelled before the trade went active — release any - // taker bond that had already been locked. - bond::release_bonds_for_order_or_warn(pool, order.id, "maker_cancel").await; + // Phase 1/6: maker cancelled before the trade went active — release any + // taker bond that had already been locked, and resolve the maker bond at + // range close (release when no slice was slashed; settle-at-close + // otherwise). + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "maker_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "maker_cancel").await; Ok(()) } @@ -457,7 +463,8 @@ async fn cancel_pending_order_from_maker( ); } } - bond::release_bonds_for_order_or_warn(pool, order.id, "pending_maker_cancel").await; + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "pending_maker_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, order, "pending_maker_cancel").await; Ok(()) } diff --git a/src/app/release.rs b/src/app/release.rs index 3ce43f57..fe0a98bd 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -234,16 +234,39 @@ pub async fn release_action( ) .await; - // Handle child order for range orders - if let Ok((Some(child_order), Some(event))) = get_child_order(ctx, order.clone(), my_keys).await - { - let client = ctx.nostr_client(); - if client.send_event(&event).await.is_err() { - tracing::warn!("Failed sending child order event for order id: {}. This may affect order synchronization", child_order.id) + // Handle child order for range orders. A spawned remainder means the + // range continues, so the maker stays committed and its bond stays + // `Locked`. No remainder means the range is fully consumed (or this was + // a fixed-amount order) — resolve the maker bond at close (Phase 6 + // settle-at-close, or the Phase 5 release for a non-range maker bond). + match get_child_order(ctx, order.clone(), my_keys).await { + Ok((Some(child_order), Some(event))) => { + let client = ctx.nostr_client(); + if client.send_event(&event).await.is_err() { + tracing::warn!("Failed sending child order event for order id: {}. This may affect order synchronization", child_order.id) + } + handle_child_order(child_order, &order, next_trade, ctx.pool(), request_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + Ok(_) => { + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "release_action").await; + } + Err(e) => { + // `get_child_order` only *computes* the remainder (it neither + // persists nor publishes a child), so on error no remainder + // exists on the book — the range has effectively ended. Resolve + // the maker bond at close rather than leaving it Locked until + // the LND CLTV safety net. (mostro does not retry child-order + // creation anywhere; the lost remainder is a pre-existing + // limitation, logged here.) + tracing::warn!( + order_id = %order.id, + error = %e, + "get_child_order failed; resolving maker bond at close (no remainder was created)" + ); + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "release_action").await; } - handle_child_order(child_order, &order, next_trade, ctx.pool(), request_id) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; } // We send a HoldInvoicePaymentSettled message to seller, the client should @@ -269,11 +292,12 @@ pub async fn release_action( ) .await; - // Phase 1: release any taker bond attached to this order before we - // hand off to the buyer payment task. Slashing is intentionally not - // wired in yet — that's Phase 2+. A failed bond release is logged but - // does not block trade finalization. - bond::release_bonds_for_order_or_warn(pool, order.id, "release_action").await; + // Phase 1/6: release the taker bond(s) on this slice. The maker bond is + // handled by `resolve_range_maker_bond_at_close_or_warn` above — its + // single HTLC may span a whole range, so releasing it here would + // wrongly cancel a bond still committed to a continuing range. A failed + // bond release is logged but does not block trade finalization. + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "release_action").await; // Finally we try to pay buyer's invoice let _ = do_payment(ctx, order, request_id).await; diff --git a/src/bitcoin_price.rs b/src/bitcoin_price.rs index 5713af96..d8236e2d 100644 --- a/src/bitcoin_price.rs +++ b/src/bitcoin_price.rs @@ -20,6 +20,19 @@ impl BitcoinPriceManager { pub fn get_price(currency: &str) -> Result { crate::price::get_bitcoin_price(currency) } + + /// Test-only: seed the in-memory price cache so unit tests in other + /// modules can exercise price-dependent paths (e.g. range-order bond + /// sizing) deterministically without hitting the network. Use a unique + /// `currency` per test to avoid cross-test interference on the shared + /// static. + #[cfg(test)] + pub(crate) fn set_price_for_test(currency: &str, price: f64) { + BITCOIN_PRICES + .write() + .expect("price cache write lock") + .insert(currency.to_string(), price); + } } #[cfg(test)] diff --git a/src/scheduler.rs b/src/scheduler.rs index 62667fa7..843d3b87 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -31,6 +31,7 @@ pub async fn start_scheduler(ctx: AppContext) { job_retry_failed_payments(ctx.clone()).await; job_process_dev_fee_payment(ctx.clone()).await; job_process_bond_payouts(ctx.clone()).await; + job_reconcile_stranded_maker_bonds(ctx.clone()).await; job_info_event_send(ctx.clone()).await; job_relay_list(ctx.clone()).await; job_update_bitcoin_prices().await; @@ -558,6 +559,9 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { crate::util::update_order_event(&keys, Status::Expired, order).await { let order_id = order_updated.id; + // Snapshot before `update` consumes the row — the + // Phase 6 close hook below needs an `&Order`. + let order_snapshot = order_updated.clone(); // Same gate as the timeout job: only release // bonds when the Expired status was actually // persisted. On persist failure the next tick @@ -575,12 +579,24 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { // expiry — Phase 1 promises "always // release" on every exit path, // expiry included. - bond::release_bonds_for_order_or_warn( + bond::release_taker_bonds_for_order_or_warn( pool, order_id, "pending_expiry", ) .await; + // Phase 6: an expiring Pending order may be a + // range remainder (or the range root) — resolve + // the maker bond at range close (release when no + // slice was slashed; settle-at-close otherwise). + // Also covers the non-range maker bond via the + // close helper's non-range release branch. + bond::resolve_range_maker_bond_at_close_or_warn( + pool, + &order_snapshot, + "pending_expiry", + ) + .await; } Err(e) => { tracing::warn!( @@ -606,6 +622,27 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { }); } +/// Phase 6 hardening: periodically retry the settle-at-close for any range +/// maker bond left `Locked` after a terminal hook's close failed (transient +/// LND/DB error). The order's terminal-state commit is never gated on close +/// success (best-effort bond design, §8.2), so without this sweep a stranded +/// parent HTLC would sit `Locked` — blocking every slashed slice's payout — +/// until the LND CLTV safety net. The close is idempotent (CAS), so the +/// retry is safe; a parent is only touched once its whole range tree is +/// terminal, so a legitimately-open range is never disturbed. Runs every +/// 5 minutes — far below the CLTV horizon, far above any useful churn. +async fn job_reconcile_stranded_maker_bonds(ctx: AppContext) { + let interval = 300u64; + + tokio::spawn(async move { + let pool = ctx.pool(); + loop { + bond::reconcile_stranded_range_maker_bonds(pool).await; + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + } + }); +} + async fn job_update_bitcoin_prices() { tokio::spawn(async { let Some(manager) = PriceManager::global() else { diff --git a/src/util.rs b/src/util.rs index e434879d..e7a43c9b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -428,28 +428,16 @@ pub async fn publish_order( } }; - // Phase 5: when the maker side is bonded, the order must NOT hit the + // Phase 5/6: when the maker side is bonded, the order must NOT hit the // order book until the maker locks an anti-abuse bond. Park it at // `WaitingMakerBond` (no NIP-33 event emitted), request the bond, and // defer the publication to `resume_publish_after_maker_bond`, which - // the bond subscriber calls on `Accepted`. Range makers are deferred - // to Phase 6 (parent/child proportional slashes), so they keep - // publishing immediately for now. + // the bond subscriber calls on `Accepted`. Both fixed-amount (Phase 5) + // and range (Phase 6) orders take this path; range orders size the + // bond against `max_amount` (worst-case exposure) and resolve slashes + // proportionally per taken slice — see `maker_bond_notional_sats`. let maker_bond_required = crate::app::bond::maker_bond_required(); - if maker_bond_required && new_order_db.is_range_order() { - // Visibility for operators: with `apply_to ∈ { make, both }` a - // range order is published WITHOUT a maker bond because - // proportional range-bond sizing/slashing is Phase 6. Without - // this log the operator could wrongly assume every order on a - // bond-enabled node is bonded. - tracing::warn!( - order_kind = %new_order_db.kind, - "publish_order: maker bond is enabled but order {} is a range order — \ - publishing WITHOUT a maker bond (range maker bonds land in Phase 6)", - new_order_db.id - ); - } - if maker_bond_required && !new_order_db.is_range_order() { + if maker_bond_required { let notional = maker_bond_notional_sats(&new_order_db)?; new_order_db.status = Status::WaitingMakerBond.to_string(); let order = new_order_db @@ -514,16 +502,40 @@ pub async fn publish_order( .await } -/// Sats notional a maker bond is sized against (Phase 5, non-range only). +/// Sats notional a maker bond is sized against. +/// +/// - **Range orders (Phase 6).** Sized against `max_amount` — the +/// worst-case fiat exposure the maker is advertising — converted at the +/// current cached price. Each taken slice later slashes a proportional +/// share of the resulting bond (`slice.fiat_amount / max_amount`), so +/// the notional must be the range ceiling, not any single slice. +/// - **Fixed-price orders.** Carry their sats `amount` directly. +/// - **Market-priced single orders.** `amount == 0` at creation, so we +/// convert the fiat amount at the current cached price — the same quote +/// `calculate_and_check_quote` validates against at order time. /// -/// Fixed-price orders carry their sats `amount` directly. Market-priced -/// single orders have `amount == 0` at creation, so we convert the fiat -/// amount at the current cached price — the same quote -/// `calculate_and_check_quote` validates against at order time. The bond -/// is a one-time snapshot and is not repriced if the market moves before -/// the order is taken (spec §10.3). Range orders never reach this path in -/// Phase 5; their `max_amount`-based sizing lands in Phase 6. +/// The bond is a one-time snapshot and is not repriced if the market moves +/// before the order is taken (spec §10.3). fn maker_bond_notional_sats(order: &Order) -> Result { + // Range orders: size against the fiat ceiling (`max_amount`). + if order.is_range_order() { + // `is_range_order()` only checks that `min`/`max` are `Some`, not + // that they are positive, so guard against a zero/negative ceiling + // here — a non-positive `max_amount` would otherwise size the bond + // at the floor and later divide-by-zero in the proportional slash + // (`record_maker_slice_slash`, which carries the matching guard). + let max_fiat = order.max_amount.filter(|m| *m > 0).ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "range order missing positive max_amount".to_string(), + )) + })?; + let price = get_bitcoin_price(&order.fiat_code)?; + if price <= 0.0 { + return Err(MostroInternalErr(ServiceError::NoAPIResponse)); + } + let sats = (max_fiat as f64 / price) * 1E8; + return Ok(sats as i64); + } if order.amount > 0 { return Ok(order.amount); } @@ -1915,4 +1927,44 @@ mod tests { }; assert_eq!(maker_bond_notional_sats(&order).unwrap(), 50_000); } + + #[test] + fn maker_bond_notional_range_sizes_against_max_at_price() { + // Phase 6: a range order sizes the notional against `max_amount` + // converted at the cached price: 100 fiat / 60_000 * 1e8 ≈ 166_666 + // sats. Unique fiat_code avoids clobbering the shared price cache. + BitcoinPriceManager::set_price_for_test("T6RANGE", 60_000.0); + let order = Order { + amount: 0, + min_amount: Some(10), + max_amount: Some(100), + fiat_code: "T6RANGE".to_string(), + fiat_amount: 0, + ..Default::default() + }; + assert!(order.is_range_order()); + assert_eq!(maker_bond_notional_sats(&order).unwrap(), 166_666); + } + + #[test] + fn maker_bond_notional_range_rejects_non_positive_max() { + // `is_range_order()` only checks `Some`-ness, so a `max_amount` of 0 + // still enters the range branch and must be rejected before bond + // sizing (it would divide-by-zero in the proportional slash). A + // `None` max can't reach here — `is_range_order()` would be false. + let order = Order { + amount: 0, + min_amount: Some(10), + max_amount: Some(0), + fiat_code: "T6ZERO".to_string(), + fiat_amount: 0, + ..Default::default() + }; + assert!(order.is_range_order()); + let err = maker_bond_notional_sats(&order).unwrap_err(); + assert!( + matches!(err, MostroInternalErr(ServiceError::UnexpectedError(_))), + "expected UnexpectedError for non-positive max_amount, got {err:?}" + ); + } } From c610c341bbc4706aa5ade0ee8a13efc8562fa400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 12 Jun 2026 16:02:02 +0200 Subject: [PATCH 08/23] fix(price): repair test-only price seeding broken by #753/#770 merge skew (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #753 removed the BITCOIN_PRICES static when it migrated price reads to the PriceManager, while PR #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 --- src/bitcoin_price.rs | 15 ++++++++------- src/price/mod.rs | 24 ++++++++++++++++++++++++ src/util.rs | 1 + 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/bitcoin_price.rs b/src/bitcoin_price.rs index d8236e2d..200e25ce 100644 --- a/src/bitcoin_price.rs +++ b/src/bitcoin_price.rs @@ -21,16 +21,17 @@ impl BitcoinPriceManager { crate::price::get_bitcoin_price(currency) } - /// Test-only: seed the in-memory price cache so unit tests in other - /// modules can exercise price-dependent paths (e.g. range-order bond - /// sizing) deterministically without hitting the network. Use a unique - /// `currency` per test to avoid cross-test interference on the shared - /// static. + /// Test-only: seed the price-override map consulted by + /// [`crate::price::get_bitcoin_price`] so unit tests in other modules + /// can exercise price-dependent paths (e.g. range-order bond sizing) + /// deterministically without hitting the network or installing the + /// global `PriceManager`. Use a unique `currency` per test to avoid + /// cross-test interference on the shared map. #[cfg(test)] pub(crate) fn set_price_for_test(currency: &str, price: f64) { - BITCOIN_PRICES + crate::price::test_price_overrides() .write() - .expect("price cache write lock") + .expect("price override write lock") .insert(currency.to_string(), price); } } diff --git a/src/price/mod.rs b/src/price/mod.rs index 3cde5fa4..4c6bf8eb 100644 --- a/src/price/mod.rs +++ b/src/price/mod.rs @@ -27,6 +27,22 @@ pub use store::{AggregatedPrice, PriceError, PriceStore}; use mostro_core::error::{MostroError, ServiceError}; +/// Test-only per-currency price overrides consulted by +/// [`get_bitcoin_price`] before the global manager. Unit tests in other +/// modules (e.g. range-order bond sizing in `util.rs`) seed this via +/// `BitcoinPriceManager::set_price_for_test` instead of installing the +/// global [`PriceManager`] — its `OnceLock` would leak one test's +/// configuration into every other test in the binary. Tests use a unique +/// currency code each to avoid cross-test interference on the shared map. +#[cfg(test)] +pub(crate) fn test_price_overrides( +) -> &'static std::sync::RwLock> { + static OVERRIDES: std::sync::OnceLock< + std::sync::RwLock>, + > = std::sync::OnceLock::new(); + OVERRIDES.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new())) +} + /// Read a currency's per-BTC price from the global [`PriceManager`]. /// /// This is the Phase 1 entry point for consumers (`util::get_bitcoin_price` @@ -36,6 +52,14 @@ use mostro_core::error::{MostroError, ServiceError}; /// legacy code returned when `BITCOIN_PRICES` was empty, so callers behave /// identically. pub fn get_bitcoin_price(currency: &str) -> Result { + #[cfg(test)] + if let Some(price) = test_price_overrides() + .read() + .expect("price override read lock") + .get(currency) + { + return Ok(*price); + } match PriceManager::global() { Some(m) => m.get_price(currency), None => Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)), diff --git a/src/util.rs b/src/util.rs index e7a43c9b..36d1ec89 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1598,6 +1598,7 @@ pub async fn notify_taker_reputation( #[cfg(test)] mod tests { use super::*; + use crate::bitcoin_price::BitcoinPriceManager; use mostro_core::message::{Message, MessageKind}; use mostro_core::order::Order; use sqlx::sqlite::SqlitePoolOptions; From be1bd5a60d2e7d36017677b461da33d13646cae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Mon, 15 Jun 2026 20:05:45 +0200 Subject: [PATCH 09/23] =?UTF-8?q?feat(bond):=20Phase=207=20=E2=80=94=20mak?= =?UTF-8?q?er=20timeout=20slash=20(#775)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(price): repair test-only price seeding broken by #753/#770 merge skew PR #753 removed the BITCOIN_PRICES static when it migrated price reads to the PriceManager, while PR #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 * 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 * docs(bond): mark Phase 7 shipped + implementation notes; fix stale Phase 4.5 row Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/ANTI_ABUSE_BOND.md | 73 +++++-- src/app/bond/slash.rs | 448 +++++++++++++++++++++++++++++++++++----- src/scheduler.rs | 38 +++- 3 files changed, 483 insertions(+), 76 deletions(-) diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index 82ab2912..d9c4be17 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -191,10 +191,10 @@ slash path. | 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | ✅ shipped (PR #738) | | 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | ✅ shipped (PR #743) | | 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | ✅ shipped (PR #744) | -| 4.5 | Re-prompt the winner for a fresh payout invoice after `send_payment` retries exhaust, instead of stranding the bond in `Failed` ([issue #750](https://github.com/MostroP2P/mostro/issues/750)) | 3 | pending | +| 4.5 | Re-prompt the winner for a fresh payout invoice after `send_payment` retries exhaust, instead of stranding the bond in `Failed` ([issue #750](https://github.com/MostroP2P/mostro/issues/750)) | 3 | ✅ shipped (PR #755) | | 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | ✅ shipped (PR #767) | | 6 | Maker bond for **range orders** with proportional slashes | 5 | ✅ shipped (PR #770) | -| 7 | Timeout slash for maker bond | 5 | pending | +| 7 | Timeout slash for maker bond | 5 | ✅ shipped (PR #775) | | 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | pending | Phases 4, 5, 6, 7 can partially overlap in time but must land in this @@ -206,16 +206,17 @@ orthogonal to the slash-direction phases — it can land any time after Phase 3, and is numbered 4.5 only because it was reported from field testing after Phase 4 shipped. -**Status as of this revision.** Phases 0 through 5 (including 4.5) are +**Status as of this revision.** Phases 0 through 6 (including 4.5) are merged on `main` (PRs #712, #719, #736, #737, #738, #743, #744, #755, -#767), and Phase 6 is implemented. The `mostro-core` pin in `Cargo.toml` -is **0.12.1**, which carries every protocol variant those phases need -(`Status::WaitingTakerBond`, `Status::WaitingMakerBond`, -`Action::PayBondInvoice`, `Payload::BondResolution`, -`Action::AddBondInvoice`, `Payload::BondPayoutRequest`, -`Action::BondInvoiceAccepted`, `Action::BondPayoutCompleted`, -`Action::BondSlashed`). Phase 6 is daemon-only (no protocol/schema -change). Phases 7–8 are not yet implemented. +#767, #770), and Phase 7 is implemented (PR #775). The `mostro-core` pin +in `Cargo.toml` is **0.12.1**, which carries every protocol variant +those phases need (`Status::WaitingTakerBond`, +`Status::WaitingMakerBond`, `Action::PayBondInvoice`, +`Payload::BondResolution`, `Action::AddBondInvoice`, +`Payload::BondPayoutRequest`, `Action::BondInvoiceAccepted`, +`Action::BondPayoutCompleted`, `Action::BondSlashed`). Phases 6 and 7 +are daemon-only (no protocol/schema change). Phase 8 is not yet +implemented. --- @@ -1914,8 +1915,9 @@ carries `parent_bond_id` / `child_order_id` / `slashed_share_sats`). `admin_cancel` (a dispute ends the range), the three `cancel.rs` order- termination paths, and the scheduler's `pending_expiry`. It is idempotent (a CAS `Locked → Slashed`) and a no-op for non-range / already-resolved - bonds. Maker-responsible **timeout** slashes for range bonds land in - Phase 7; until then a maker-timeout cancel releases (no slash). + bonds. Maker-responsible **timeout** slashes for range bonds shipped in + Phase 7 (a per-slice child slash via this same path; the scheduler's + terminal cancel branch then runs the close). - **"One slash row per slice" is enforced at the schema level.** Besides the atomic `INSERT ... WHERE NOT EXISTS` in `record_maker_slice_slash` (which already wins/loses the TOCTOU race correctly), a partial UNIQUE index on @@ -2035,7 +2037,7 @@ the maker. --- -## 12. Phase 7 — Maker timeout slash +## 12. Phase 7 — Maker timeout slash ✅ Completed Gate: `enabled && slash_on_waiting_timeout && apply_to ∈ { make, both }`. @@ -2052,6 +2054,49 @@ Phase 6 partial-slash path. Tests mirror Phase 4 from the maker side; the "no slash" rows in the §9.2 table become "slash maker bond". +**Implementation notes (as shipped, PR #775).** Daemon-only — no +`mostro-core` change, no migration (reuses `Action::BondSlashed` from +Phase 4 and the Phase 6 child-slash schema). + +- **The gate is per responsible role.** `slash_or_release_on_timeout` + maps the §9.2 responsible side to maker/taker via the §3.1 order-kind + mapping and checks `apply_to` against *that* posting role + (`applies_to_maker()` / `applies_to_taker()`). A leftover `Locked` + maker bond under `apply_to = take` therefore still releases — the + gate is about who the node's policy covers, not "any side has a + bond". +- **Range-aware bond resolution.** The responsible bond resolves + through the Phase 2/6 `resolve_slash_target` primitive: pubkey match + on the order's own bonds first, then the range-root walk for the + maker side (the maker bond of a range order lives on the root, not + the slice). +- **Non-range maker slash** settles the HTLC inline via the Phase 2 + `slash_one` primitive and confirms through the durable + `slashed_reason = Timeout` witness, exactly like the taker path. +- **Range maker slash** records a proportional child row + (`record_maker_slice_slash`, `reason = Timeout`) and leaves the + parent HTLC `Locked` — settle-at-close, per Phase 6. The dispatch + reports the *child* row, so the `Action::BondSlashed` notice carries + the slice's slashed amount. `record_maker_slice_slash` now returns + whether it actually inserted; the notice fires only on a fresh + insert, so a scheduler retry (order persist failed, next tick + re-runs) never re-notifies — the range parent stays `Locked` by + design, so the Phase 4 "no `Locked` bond on re-entry" guarantee + cannot provide this and the insert flag does instead. +- **Range close on the terminal cancel.** A maker-responsible timeout + cancels the order outright (no remainder is spawned on a cancel), so + the range terminates. The scheduler's cancel branch runs + `resolve_range_maker_bond_at_close_or_warn` right after the + `Canceled` status persists: the parent settles once and the + per-slice counterparty shares + maker refund distribute promptly via + Phase 3, instead of waiting for the 5-minute reconciliation sweep + (which remains the backstop on transient failure). The republish + branch never closes — the order returns to the book with the maker + still committed and its bond `Locked`. +- **The timeout release loop retains a range maker parent** alongside + the existing retain-on-republish carve-out: the parent spans the + whole range and is only ever resolved at range close. + --- ## 13. Phase 8 — Public exposure + docs diff --git a/src/app/bond/slash.rs b/src/app/bond/slash.rs index a66c7373..00a6189d 100644 --- a/src/app/bond/slash.rs +++ b/src/app/bond/slash.rs @@ -375,18 +375,26 @@ async fn order_has_range_maker_bond( /// Responsibility maps directly from the waiting state: /// `WaitingBuyerInvoice → buyer`, `WaitingPayment → seller`. The /// buyer/seller → bond-row resolution then reuses the §3.1 order-kind -/// mapping baked into [`apply_bond_resolution`] (a slash flag is matched -/// against `order.buyer_pubkey` / `order.seller_pubkey`, which equal the -/// bonded taker's trade pubkey). So under `apply_to = "take"` only the -/// taker side ever carries a bond, and the maker-responsible rows of the -/// §9.2 table fall through to release. +/// mapping (a slash side is matched against `order.buyer_pubkey` / +/// `order.seller_pubkey`, with the Phase 6 range-root fallback for a +/// maker bond living on the range root). Phase 4 armed the taker side of +/// the §9.2 table; Phase 7 fills the maker-responsible rows — the gate +/// checks `apply_to` against the *responsible* party's posting role. /// /// A slash happens **only** when all of the following hold: /// - the feature is enabled, `slash_on_waiting_timeout = true`, and -/// `apply_to` covers the taker; +/// `apply_to` covers the responsible party's posting role (taker since +/// Phase 4, maker since Phase 7); /// - the order is in a waiting state; /// - the responsible party holds a `Locked` bond. /// +/// A maker-responsible slash on a **range** order goes through the Phase 6 +/// partial-slash path: a proportional child row is recorded and the parent +/// HTLC stays `Locked` (the single settle happens at range close — the +/// scheduler's cancel branch runs the close right after the order +/// terminates, with the reconciliation sweep as backstop). The returned +/// bond is then the *child* row, carrying the slice's slashed amount. +/// /// Otherwise every active bond is released. This preserves today's /// behaviour when the feature is off, when the bond belongs to the /// non-responsible party, or when no bond exists — and it is the path @@ -469,21 +477,35 @@ pub async fn slash_or_release_on_timeout( // republish-vs-cancel split (keep the two in sync). let republishes = order_republishes_on_timeout(order); - // Gate the slash. `apply_to` is a posting-timing switch; Phase 4 is - // taker-only, so we check `applies_to_taker` (Phase 7 widens this to - // the maker). When the gate is closed we still release — bonds left + // Gate the slash per responsible role. `apply_to` is a posting-timing + // switch: Phase 4 armed the taker side, Phase 7 widens to the maker — + // the slash only arms when `apply_to` covers the *responsible* party's + // posting role. When the gate is closed we still release — bonds left // over from a prior enabled period must drain regardless (but a // republish still retains the maker bond). - let slash_armed = bond_cfg - .is_some_and(|c| c.enabled && c.slash_on_waiting_timeout && c.apply_to.applies_to_taker()); + let responsible_is_maker = side_is_maker(order, side)?; + let slash_armed = bond_cfg.is_some_and(|c| { + c.enabled + && c.slash_on_waiting_timeout + && if responsible_is_maker { + c.apply_to.applies_to_maker() + } else { + c.apply_to.applies_to_taker() + } + }); if !slash_armed { release_on_timeout(pool, order.id, republishes).await; return Ok(None); } - // Does the responsible party hold a `Locked` bond? + // Does the responsible party hold a `Locked` bond? Phase 7: a range + // order's maker bond lives on the range *root*, so the resolver must be + // range-aware (`resolve_slash_target` falls back to the root walk for + // the maker side; the taker side resolves on this order's bonds alone, + // exactly as Phase 4 did). let bonds = find_active_bonds_for_order(pool, order.id).await?; - let Some(responsible) = resolve_locked_bond(order, &bonds, side).cloned() else { + let is_range = order_has_range_maker_bond(pool, order).await?; + let Some(responsible) = resolve_slash_target(pool, order, &bonds, side, is_range).await? else { // Responsible party has no bond (e.g. the maker under // `apply_to = take`), or the bond already moved out of `Locked`. // No slash; release whatever is still active on the order @@ -492,29 +514,71 @@ pub async fn slash_or_release_on_timeout( return Ok(None); }; - // Settle the responsible bond's HTLC + CAS → PendingPayout(Timeout) - // (the Phase 2 `slash_one` primitive), then resolve the remaining - // active bonds: release them — but on a republish retain the maker's - // still-`Locked` bond, which the abandoning taker's timeout must not - // disturb. (We intentionally do **not** route this through - // `apply_bond_resolution`, which always releases every non-slashed - // bond — that is correct for a terminal dispute resolution but would - // wrongly release the maker on a republish.) + // Slash the responsible bond, then resolve the remaining active bonds: + // release them — but on a republish retain the maker's still-`Locked` + // bond, which the abandoning taker's timeout must not disturb. (We + // intentionally do **not** route this through `apply_bond_resolution`, + // which always releases every non-slashed bond — that is correct for a + // terminal dispute resolution but would wrongly release the maker on a + // republish.) + // + // Two slash shapes (mirroring `apply_bond_resolution`): + // - **Range maker** (Phase 7 × Phase 6): record a proportional child + // slash row and leave the parent HTLC `Locked` — the single settle + // happens at range close (`resolve_range_maker_bond_at_close`, run + // by the scheduler's cancel branch / reconciliation sweep once the + // order terminates). The notice-worthy row is the *child* (it carries + // the slice's slashed amount), and only when this call actually + // inserted it — an idempotent re-run must not re-notify. + // - **Everything else** (taker, non-range maker): settle the HTLC + // inline via the Phase 2 `slash_one` primitive and confirm through + // the durable `slashed_reason = Timeout` witness. let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct); - slash_one( - pool, - ln_client, - &responsible, - BondSlashReason::Timeout, - node_share_pct, - ) - .await; + let slashed_row: Option = if responsible_is_maker && is_range { + let root = find_range_root_order(pool, order.clone()).await?; + let inserted = record_maker_slice_slash( + pool, + order, + &root, + &responsible, + BondSlashReason::Timeout, + node_share_pct, + ) + .await?; + if inserted { + find_slice_slash_child(pool, responsible.id, order.id).await? + } else { + None + } + } else { + slash_one( + pool, + ln_client, + &responsible, + BondSlashReason::Timeout, + node_share_pct, + ) + .await; + // Confirm the slash actually landed before claiming it: a transient + // settle failure leaves the bond `Locked` (`slash_one` is + // best-effort), and we must never tell a user their bond was + // forfeited while the HTLC is still theirs. + if timeout_slash_confirmed(pool, responsible.id).await? { + Some(responsible.clone()) + } else { + None + } + }; + let maker = BondRole::Maker.to_string(); for bond in bonds.iter() { if bond.id == responsible.id { continue; } - if republishes && bond.role == maker { + // Retain the maker bond when the order survives (republish) or when + // it is a range parent — the latter spans the whole range and is + // resolved only at range close, never inline here. + if bond.role == maker && (republishes || is_range) { continue; } if let Err(e) = release_bond(pool, bond).await { @@ -526,28 +590,44 @@ pub async fn slash_or_release_on_timeout( } } - // Confirm the slash actually landed before claiming it: a transient - // settle failure leaves the bond `Locked` (`slash_one` is best-effort), - // and we must never tell a user their bond was forfeited while the HTLC - // is still theirs. - if timeout_slash_confirmed(pool, responsible.id).await? { - info!( - bond_id = %responsible.id, - order_id = %order.id, - role = %responsible.role, - "Bond slashed on waiting-state timeout" - ); - Ok(Some(responsible)) - } else { - warn!( - bond_id = %responsible.id, - order_id = %order.id, - "timeout slash did not land (bond still Locked); no forfeiture notice sent" - ); - Ok(None) + match slashed_row { + Some(slashed) => { + info!( + bond_id = %slashed.id, + order_id = %order.id, + role = %slashed.role, + "Bond slashed on waiting-state timeout" + ); + Ok(Some(slashed)) + } + None => { + warn!( + bond_id = %responsible.id, + order_id = %order.id, + "timeout slash did not land (still Locked / already slashed); no forfeiture notice sent" + ); + Ok(None) + } } } +/// Fetch the Phase 6 child slash row recorded for `(parent_bond_id, +/// slice_order_id)` — the row the Phase 7 range-maker timeout path returns +/// to the caller for the `BondSlashed` notice (it carries the slice's +/// proportional slashed amount, not the whole parent bond). +async fn find_slice_slash_child( + pool: &Pool, + parent_bond_id: Uuid, + slice_order_id: Uuid, +) -> Result, MostroError> { + sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?") + .bind(parent_bond_id) + .bind(slice_order_id) + .fetch_optional(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + /// Confirm a timeout slash actually landed on `bond_id`, regardless of /// where the concurrent payout scheduler has since moved the row. /// @@ -755,6 +835,12 @@ async fn slash_one( /// publication and take), times the locked bond amount. The child row's /// `order_id` and maker-side `pubkey` are the slice's, so the Phase 3 /// recipient resolver pays the slice's *other* side (the winner). +/// +/// Returns `Ok(true)` when a child row was actually inserted by this call, +/// `Ok(false)` on every idempotent skip (slice already slashed, parent no +/// longer `Locked`, degenerate share). The Phase 7 timeout path keys the +/// one-shot `BondSlashed` notice on this so a scheduler retry never +/// re-notifies the maker for a slash that already landed. async fn record_maker_slice_slash( pool: &Pool, slice: &Order, @@ -762,7 +848,7 @@ async fn record_maker_slice_slash( parent_bond: &Bond, reason: BondSlashReason, node_share_pct: f64, -) -> Result<(), MostroError> { +) -> Result { let kind = slice.get_order_kind().map_err(MostroInternalErr)?; let maker_slice_pubkey = match kind { Kind::Sell => slice.seller_pubkey.as_deref(), @@ -774,7 +860,7 @@ async fn record_maker_slice_slash( slice_order_id = %slice.id, "record_maker_slice_slash: slice has no maker-side pubkey; skipping" ); - return Ok(()); + return Ok(false); }; let Some(max_fiat) = root.max_amount.filter(|m| *m > 0) else { warn!( @@ -782,7 +868,7 @@ async fn record_maker_slice_slash( root_order_id = %root.id, "record_maker_slice_slash: range root missing positive max_amount; skipping" ); - return Ok(()); + return Ok(false); }; // Price-invariant proportional share, clamped so the cumulative slashed @@ -798,7 +884,7 @@ async fn record_maker_slice_slash( raw, remaining, "record_maker_slice_slash: computed non-positive / over-allocated share; skipping" ); - return Ok(()); + return Ok(false); } let now = Utc::now().timestamp(); @@ -871,7 +957,7 @@ async fn record_maker_slice_slash( slice_order_id = %slice.id, "record_maker_slice_slash: slice already slashed (unique index); skipping duplicate" ); - return Ok(()); + return Ok(false); } Err(e) => { return Err(MostroInternalErr(ServiceError::DbAccessError( @@ -888,7 +974,7 @@ async fn record_maker_slice_slash( slice_order_id = %slice.id, "record_maker_slice_slash: slice already slashed or parent no longer Locked; skipping" ); - return Ok(()); + return Ok(false); } // Recompute the parent's running total from the authoritative slice @@ -916,7 +1002,7 @@ async fn record_maker_slice_slash( slash_amount, "Phase 6: recorded proportional maker slice slash (parent HTLC stays Locked)" ); - Ok(()) + Ok(true) } /// Phase 6 — resolve a maker bond when its order (range chain) terminates @@ -2431,6 +2517,256 @@ mod tests { ); } + // ── Phase 7 — maker timeout slash ─────────────────────────────────────── + + #[tokio::test] + async fn timeout_maker_responsible_slashes_maker_bond_sell_order() { + // Phase 7: sell order, WaitingPayment — the seller is responsible + // and on a sell order the seller is the *maker*. With apply_to=make + // armed, the maker's bond is slashed with reason=Timeout (the §9.2 + // "no slash" row filled in by Phase 7). + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!( + result.map(|b| b.id), + Some(maker_bond.id), + "must report the slashed maker bond for notification" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "the non-range maker slash settles the HTLC inline" + ); + let row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, BondState::PendingPayout.to_string()); + assert_eq!(row.1.as_deref(), Some("timeout")); + } + + #[tokio::test] + async fn timeout_maker_responsible_buy_order_slashes_maker_and_releases_taker() { + // Phase 7 buy-order mirror: WaitingBuyerInvoice → buyer responsible, + // and on a buy order the buyer is the maker. Under apply_to=both the + // taker (seller) also holds a bond — it belongs to the + // non-responsible party, so it must be released, never slashed, and + // never retained (the timeout cancels the order outright). + let pool = setup_pool().await; + let order = waiting_order( + Kind::Buy, + taker_pk(), + maker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!(result.map(|b| b.id), Some(maker_bond.id)); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::PendingPayout.to_string(), + "maker bond slashed on maker-responsible timeout" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "the non-responsible taker's bond is released on the terminal cancel" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "only the slashed maker HTLC is settled" + ); + } + + #[tokio::test] + async fn timeout_maker_slash_skipped_when_apply_to_take_only() { + // Per-role gate: a maker-responsible timeout with a leftover Locked + // maker bond (posted during a prior apply_to=make period) must NOT + // slash under apply_to=take — the gate checks the responsible + // party's posting role, not "any side is covered". The bond drains + // via release instead. + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Take); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none()); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Released.to_string() + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_records_child_and_keeps_parent_locked() { + // Phase 7 × Phase 6: a maker-responsible timeout on a *range* order + // goes through the partial-slash path — a proportional child row is + // recorded (slice fiat 40 / max 100 × bond 1000 = 400, reason + // timeout) and the parent HTLC stays Locked; the single settle + // happens at range close, never here. The reported bond is the + // child (it carries the slice's slashed amount for the notice). + // The taker's own bond is released on the terminal cancel. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::WaitingPayment.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let taker_bond = insert_bond(&pool, root.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg)) + .await + .unwrap(); + + let child = result.expect("range maker timeout must report the child slash row"); + assert_eq!(child.parent_bond_id, Some(parent.id)); + assert_eq!(child.child_order_id, Some(root.id)); + assert_eq!(child.amount_sats, 400, "proportional slice share"); + assert_eq!(child.state, BondState::PendingPayout.to_string()); + assert_eq!(child.slashed_reason.as_deref(), Some("timeout")); + assert_eq!(child.pubkey, maker_pk(), "the maker is the slashed party"); + + assert!( + ln.calls().is_empty(), + "the parent HTLC must NOT be settled mid-range (settle-at-close)" + ); + let parent_row = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(parent_row.state, BondState::Locked.to_string()); + assert_eq!(parent_row.slashed_share_sats, 400); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond released on the terminal cancel" + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_resolves_root_bond_from_descendant_slice() { + // The maker bond lives on the range *root*; a timeout on a + // descendant slice (range remainder spawned by an earlier release) + // must walk `range_parent_id` to find it and record the child slash + // against the slice's own order id. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 0, 10, 100); + insert_range_order_row(&pool, &root).await; + let mut slice = range_slice(Kind::Sell, maker_pk(), taker_pk(), 25, 10, 100); + slice.status = Status::WaitingPayment.to_string(); + slice.range_parent_id = Some(root.id); + insert_range_order_row(&pool, &slice).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &slice, Some(&cfg)) + .await + .unwrap(); + + let child = result.expect("slice timeout must resolve the root's maker bond"); + assert_eq!(child.parent_bond_id, Some(parent.id)); + assert_eq!(child.child_order_id, Some(slice.id)); + assert_eq!(child.amount_sats, 250, "25/100 of the 1000-sat bond"); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, parent.id).await, + BondState::Locked.to_string() + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_is_idempotent_per_slice() { + // A slice whose share was already slashed (e.g. by a dispute, or by + // a prior tick whose order-persist failed) must not be slashed + // twice, and — crucially for the notice — the dispatch must report + // None so the maker is never re-notified for the same slash. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::WaitingPayment.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + // Pre-existing child slash for this same slice. + let inserted = record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + assert!(inserted, "fixture insert must land"); + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg)) + .await + .unwrap(); + + assert!( + result.is_none(), + "an already-slashed slice must not re-notify" + ); + assert!(ln.calls().is_empty()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "no duplicate child row"); + assert_eq!( + children[0].slashed_reason.as_deref(), + Some("lost-dispute"), + "the original slash row is untouched" + ); + } + #[tokio::test] async fn timeout_no_config_releases_bond() { // No [anti_abuse_bond] block (cfg = None): the dispatch still diff --git a/src/scheduler.rs b/src/scheduler.rs index 843d3b87..ca823511 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -482,13 +482,39 @@ async fn job_cancel_orders(ctx: AppContext) { // that strips eligibility) — on persist failure // the next tick retries this branch only; the // slash is durable in `bonds.slashed_reason` and - // a re-entry sees no `Locked` bond, so it is a + // a re-entry sees no `Locked` bond (or an + // already-recorded slice child), so it is a // no-op (no duplicate notify). - if let Err(e) = order_updated.update(pool).await { - tracing::warn!( - "scheduler_timeout: persist failed for order {} ({}); will retry next tick", - order_id, e - ); + match order_updated.update(pool).await { + Ok(_) => { + // Phase 7: a maker-responsible timeout + // cancels the order outright, terminating + // its range chain — resolve the range + // maker bond at close (settle + per-slice + // payouts + maker refund when a slice was + // slashed; plain release otherwise). The + // close helper is idempotent and a cheap + // no-op for non-range / already-resolved + // bonds; on transient failure the + // reconciliation sweep retries. The + // republish branch must NOT close: the + // order returns to the book with the + // maker still committed. + if matches!(new_status, Status::Canceled) { + bond::resolve_range_maker_bond_at_close_or_warn( + pool, + &order, + "scheduler_timeout", + ) + .await; + } + } + Err(e) => { + tracing::warn!( + "scheduler_timeout: persist failed for order {} ({}); will retry next tick", + order_id, e + ); + } } } } From 1d6c5baded1a24aed2d02f1299e1d3149dfcacb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Tue, 16 Jun 2026 14:17:34 +0200 Subject: [PATCH 10/23] =?UTF-8?q?docs(bond):=20Phase=208=20=E2=80=94=20pub?= =?UTF-8?q?lic=20config=20exposure=20+=20operator=20docs=20(#777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (#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) * docs(bond): reference PR #777 in Phase 8 spec status Co-Authored-By: Claude Opus 4.8 (1M context) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- README.md | 45 ++++++++++++++++- docs/ANTI_ABUSE_BOND.md | 28 +++++++---- docs/ARCHITECTURE.md | 54 ++++++++++++++++++++- docs/LIGHTNING_OPS.md | 105 ++++++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 741ee032..01acf047 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ While @lnp2pBot works excellently, it relies on Telegram—a platform potentiall - **User Reputation** - Peer rating system to build trust between traders - **Automatic Timeouts** - Configurable expiration for orders and payment windows - **Payment Retry Logic** - Automatic retry for failed Lightning payments with configurable attempts +- **Anti-Abuse Bond** - Optional, opt-in Lightning hold-invoice bond for makers and/or takers; released on honest trades, slashed only on a solver dispute directive or a waiting-state timeout (off by default — see [docs/ANTI_ABUSE_BOND.md](docs/ANTI_ABUSE_BOND.md)) ### Advanced Features - **Development Fee System** - Transparent fee collection with Nostr audit events (kind 8383) @@ -202,6 +203,48 @@ sequenceDiagram --- +### Anti-Abuse Bond Flow (optional) + +Operators can require an **opt-in** anti-abuse bond — a *second* Lightning +hold invoice, separate from the trade escrow — to discourage no-shows and +griefing. It is **off by default**; nodes that leave +`[anti_abuse_bond].enabled = false` behave exactly as before. Full design: +[docs/ANTI_ABUSE_BOND.md](docs/ANTI_ABUSE_BOND.md). + +The feature deliberately keeps two axes separate: + +- **Maker / taker — *who posts the bond, and when.*** The bond is locked + at order-creation time (maker) or take time (taker). The `apply_to` + setting (`take` | `make` | `both`) selects which side(s) post one. +- **Buyer / seller — *whose action can trigger a slash.*** All trade + duties (paying the hold invoice, sending the buyer invoice, sending + fiat, releasing) are buyer/seller duties, so a solver's slash directive + is expressed as `slash_seller` / `slash_buyer`. The order kind maps + these to the right bond row (sell → maker is seller; buy → maker is + buyer). + +Lifecycle: + +1. **Lock.** On take/create, Mostro sends the bonded party an + `Action::PayBondInvoice`; once the HTLC is held, the bond is `Locked` + and the trade proceeds. +2. **Release (the common case).** On normal completion, or on any cancel + before a waiting-state timeout, the bond hold invoice is cancelled and + the bond is `Released` — nothing is captured. +3. **Slash (only when unambiguous).** Either a solver directs it via the + `BondResolution` payload on `admin-settle` / `admin-cancel`, or a + waiting-state timeout elapses (when `slash_on_waiting_timeout = true`). + The bond HTLC is settled into Mostro's wallet, then split per + `slash_node_share_pct`: the node keeps its share (funds solver + compensation) and the winning counterparty is paid the rest + asynchronously after submitting a payout invoice. + +Nodes advertise their bond policy in the kind-38385 info event +(`bond_enabled`, `bond_apply_to`, `bond_amount_pct`, …) so clients can +warn users before they trade. + +--- + ### Development Fee Distribution Mostro implements a transparent development sustainability model: @@ -541,7 +584,7 @@ For better separation of secrets from config, Mostro can read the nsec from the Three common ways to provide it: 1. **`~/.mostro/.env`** (auto-loaded at startup, `chmod 600` recommended): - ``` + ```bash MOSTRO_NSEC_PRIVKEY=nsec1... ``` The interactive setup wizard can create this file for you. diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index d9c4be17..79ee8b90 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -195,7 +195,7 @@ slash path. | 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | ✅ shipped (PR #767) | | 6 | Maker bond for **range orders** with proportional slashes | 5 | ✅ shipped (PR #770) | | 7 | Timeout slash for maker bond | 5 | ✅ shipped (PR #775) | -| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | pending | +| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | ✅ shipped (PR #777) | Phases 4, 5, 6, 7 can partially overlap in time but must land in this order on `main` to keep review scope honest. Phase 3.5 depends only on @@ -206,17 +206,18 @@ orthogonal to the slash-direction phases — it can land any time after Phase 3, and is numbered 4.5 only because it was reported from field testing after Phase 4 shipped. -**Status as of this revision.** Phases 0 through 6 (including 4.5) are +**Status as of this revision.** Phases 0 through 7 (including 4.5) are merged on `main` (PRs #712, #719, #736, #737, #738, #743, #744, #755, -#767, #770), and Phase 7 is implemented (PR #775). The `mostro-core` pin -in `Cargo.toml` is **0.12.1**, which carries every protocol variant -those phases need (`Status::WaitingTakerBond`, +#767, #770, #775), and Phase 8 is implemented (PR #777). The +`mostro-core` pin in `Cargo.toml` is **0.12.1**, which carries every +protocol variant those phases need (`Status::WaitingTakerBond`, `Status::WaitingMakerBond`, `Action::PayBondInvoice`, `Payload::BondResolution`, `Action::AddBondInvoice`, `Payload::BondPayoutRequest`, `Action::BondInvoiceAccepted`, -`Action::BondPayoutCompleted`, `Action::BondSlashed`). Phases 6 and 7 -are daemon-only (no protocol/schema change). Phase 8 is not yet -implemented. +`Action::BondPayoutCompleted`, `Action::BondSlashed`). Phases 6, 7 and 8 +are daemon-only (no protocol/schema change) — Phase 8 in particular adds +no code beyond the info-event tags already shipped in Phase 3 (§13.1); it +is documentation polish. The feature is now **complete**. --- @@ -2099,7 +2100,16 @@ Phase 4 and the Phase 6 child-slash schema). --- -## 13. Phase 8 — Public exposure + docs +## 13. Phase 8 — Public exposure + docs ✅ Completed + +The info-event tags (the load-bearing code change) shipped early in +Phase 3 (PR #738). This phase lands the remaining documentation polish: +the `docs/ARCHITECTURE.md` bond flow + per-action entries + §3.1 axes +note, the `docs/LIGHTNING_OPS.md` operator runbook, and the README +overview. The upstream `admin_settle_order.html` / +`admin_cancel_order.html` updates live in the `mostro.network` protocol +docs repo, not here; the per-release `CHANGELOG.md` is generated by the +release tooling rather than hand-edited per PR. ### 13.1 Scope diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c2cc3d0e..a0046108 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,7 @@ flowchart LR - Orders: `order.rs`, `take_buy.rs`, `take_sell.rs`, `cancel.rs`, `release.rs`, `add_invoice.rs`, `fiat_sent.rs`, `orders.rs`, `restore_session.rs`, `trade_pubkey.rs`, `rate_user.rs`, `dispute.rs`. - Admin: `admin_cancel.rs`, `admin_settle.rs`, `admin_add_solver.rs`, `admin_take_dispute.rs`. +- Anti-abuse bond (`app/bond/*`): `flow.rs` (lock/release lifecycle), `slash.rs` (dispute + timeout slash), `payout.rs` (counterparty payout), `db.rs`/`model.rs` (the `bonds` table), `math.rs` (amount/split math), `types.rs` (`BondRole`, `BondState`, `BondSlashReason`). Opt-in and off by default; see `docs/ANTI_ABUSE_BOND.md`. - Router: `app.rs:handle_message_action` matches `mostro_core::message::Action` and calls module functions. ### Per‑Action Summaries @@ -62,7 +63,17 @@ flowchart LR - `app/orders.rs` – queries and returns order listings/history. - `app/restore_session.rs` – rehydrates context for a client after reconnect. - `app/trade_pubkey.rs` – exchanges/updates trade pubkeys for secure comms. -- Admin modules – force cancel/settle, take disputes, add solvers; guarded, auditable, and permission-gated for solver capabilities. +- Admin modules – force cancel/settle, take disputes, add solvers; guarded, auditable, and permission-gated for solver capabilities. `admin_settle`/`admin_cancel` also carry the optional `BondResolution` payload that lets a solver slash a bond independently of the trade outcome. +- `app/bond/*` – optional anti-abuse bond: a *second* Lightning hold invoice the maker and/or taker locks when entering a trade. Released on normal completion and on cancels before a waiting-state timeout; slashed only on an explicit solver `BondResolution` directive or a waiting-state timeout (gated by `slash_on_waiting_timeout`). Wired into `take_buy`/`take_sell` (lock), every cancel/release/admin path (release), and the scheduler (timeout slash + payout). Disabled by default. + +### Two axes the bond keeps separate + +The bond feature deliberately distinguishes two axes (see `docs/ANTI_ABUSE_BOND.md` §3.1): + +- **Maker / taker — *who posted the bond, and when.*** The bond is requested at order-creation time (maker) or take time (taker). The `apply_to` setting (`take` | `make` | `both`) is a maker/taker switch; `BondRole` is a maker/taker enum. +- **Buyer / seller — *whose action triggers a slash.*** All trade-flow duties (paying the hold invoice, providing the buyer invoice, sending fiat, releasing) are buyer/seller duties, so the solver's `BondResolution` carries `slash_seller` / `slash_buyer`, never `slash_maker` / `slash_taker`. + +The order kind fixes the mapping: on a `sell` order the maker is the seller and the taker is the buyer; on a `buy` order the maker is the buyer and the taker is the seller. The daemon resolves a `slash_seller`/`slash_buyer` directive to the right bond row internally. ## Configuration Constants (src/config/constants.rs) @@ -268,6 +279,47 @@ sequenceDiagram AdminCancel->>DB: mark canceled; audit trail ``` +### Flow: Anti-Abuse Bond (Lock → Resolve) + +Opt-in (`[anti_abuse_bond].enabled = true`). The bond is a second hold +invoice, separate from the trade escrow. Shown for a taker on a buy order; +the maker side (Phase 5+) is symmetric. + +```mermaid +sequenceDiagram + participant Taker as Taker (Nostr) + participant Mostro as app.rs + participant Bond as app/bond/* + participant Sched as scheduler.rs + participant DB as db.rs + participant LND as lightning/mod.rs + + Taker->>Mostro: Message(Action=TakeBuy) + Mostro->>Bond: request_taker_bond(...) + Bond->>LND: create_hold_invoice(bond amount) + Bond->>DB: insert bonds row (state=Requested) + Bond-->>Taker: Action=PayBondInvoice (bolt11) + Taker->>LND: pay bond hold invoice (HTLC held) + LND-->>Bond: InvoiceState=Accepted + Bond->>DB: state=Locked; copy taker_* onto order; resume take + alt normal completion or pre-timeout cancel + Mostro->>Bond: release_bond(...) + Bond->>LND: cancel_hold_invoice(bond hash) + Bond->>DB: state=Released + else solver directive (BondResolution) or waiting-state timeout + Mostro->>Bond: slash (admin_settle/cancel) or + Sched->>Bond: slash_or_release_on_timeout(...) + Bond->>LND: settle_hold_invoice(preimage) %% claims bond into Mostro wallet + Bond->>DB: state=PendingPayout; freeze node_share_sats + Bond-->>Taker: Action=BondSlashed (timeout path) + Sched->>Bond: job_process_bond_payouts + Bond-->>Counterparty: Action=AddBondInvoice (asks for payout bolt11) + Counterparty-->>Bond: Action=AddBondInvoice (payout bolt11) + Bond->>LND: send_payment(counterparty share) + Bond->>DB: state=Slashed (or Forfeited if claim window lapses) + end +``` + ## Lightning Operations - `create_hold_invoice(description, amount)` → returns invoice, preimage, hash. diff --git a/docs/LIGHTNING_OPS.md b/docs/LIGHTNING_OPS.md index 054977e3..3924ddf4 100644 --- a/docs/LIGHTNING_OPS.md +++ b/docs/LIGHTNING_OPS.md @@ -176,6 +176,111 @@ Retrieves LND node information including: **Usage**: Called during startup to populate `config::LN_STATUS` (src/main.rs:86) +## Anti-Abuse Bond Operations + +The optional anti-abuse bond (`[anti_abuse_bond]`, off by default) puts a +**second** hold invoice on a trade, owned by the maker and/or taker. It is +released on normal completion and on cancels before a waiting-state +timeout; it is slashed only on an explicit solver `BondResolution` +directive or a waiting-state timeout (when `slash_on_waiting_timeout = +true`). Full design: `docs/ANTI_ABUSE_BOND.md`. This section is the +operator runbook. + +### Where the state lives + +Every bond is one row in the `bonds` table (`src/app/bond/db.rs`, +`model.rs`). Inspect it directly: + +```sql +SELECT id, order_id, role, state, amount_sats, + parent_bond_id, child_order_id, slashed_share_sats, + node_share_sats, slashed_reason, + payout_attempts, invoice_request_attempts, slashed_at + FROM bonds ORDER BY created_at; +``` + +`state` (string-backed, `src/app/bond/types.rs`) walks: + +```text +requested → locked ─┬→ released (happy / cancel before timeout) + └→ pending-payout ─┬→ slashed (counterparty paid their share) + ├→ forfeited (counterparty never claimed in window) + └→ failed (send_payment exhausted) +``` + +- **`pending-payout`** — a slash already fired. The bond HTLC was + **settled** (claimed into Mostro's wallet) at slash time; the scheduler + is now driving the counterparty payout. The split is frozen here: + `node_share_sats` is the node's retained share, `amount_sats - + node_share_sats` is owed to the winning counterparty. +- **`slashed`** — terminal success; the counterparty share was paid. +- **`forfeited`** — designed-in long-stop: the counterparty never sent a + payout invoice within `payout_claim_window_days`. The node keeps + `amount_sats` in full. **No operator action needed.** +- **`failed`** — `send_payment` exhausted `payout_max_retries` against a + delivered invoice. **User-recoverable** while inside the claim window: a + fresh `Action::AddBondInvoice` from the recipient flips the row back to + `pending-payout`. Only past the window does it need operator attention + (see below). +- `slashed_reason` is `lost-dispute` (solver directive) or `timeout` + (waiting-state timeout). A cancel before the timeout is never a slash. + +For range-order maker bonds the parent row stays `locked` while child +rows (`parent_bond_id` set, `child_order_id` = the taken slice) carry the +proportional per-slice slashes; the single settle happens at range close. + +### Scheduler jobs + +Run from `src/scheduler.rs` (see `run_jobs`): + +- `job_process_bond_payouts` — drives every `pending-payout` row: requests + a payout bolt11 from the winner (`Action::AddBondInvoice`, cadenced by + `payout_invoice_window_seconds`), runs `send_payment`, retries up to + `payout_max_retries`, and reconciles against LND on entry so a daemon + restart never double-pays. +- `job_reconcile_stranded_maker_bonds` — settles and distributes a range + maker bond at range close (per-slice counterparty shares + maker + refund); the 5-minute sweep is the backstop if the inline close failed. + +### Reading what happened in the logs + +Bond transitions log through `tracing` (`bond payout: …` lines in +`src/app/bond/payout.rs`, plus slash/release lines in `flow.rs` / +`slash.rs`). To follow a solver decision, look for the `BondResolution` +on the inbound `admin-settle` / `admin-cancel` message — its wire shape is: + +```json +{ "order": { "version": 1, "id": "", "action": "admin-cancel", + "payload": { "bond_resolution": { "slash_seller": true, "slash_buyer": false } } } } +``` + +`slash_seller` / `slash_buyer` are resolved to a maker- or taker-bond row +by the order kind (sell → maker is seller; buy → maker is buyer). A +`payload: null` (or absent) means **release both bonds** — no slash. A +slash directed at a side with no `locked` bond is rejected with +`CantDo(InvalidPayload)` and the trade resolution does not run. + +### Resolving a `failed` bond manually + +A `failed` row means the bond was slashed (sats are already in Mostro's +wallet), but Mostro could not route the counterparty's share and the +claim window has since elapsed, so the auto-recovery path no longer +re-arms it. There is no slash to undo and no funds at risk on the +counterparty's side — the value is held by the node. To make the +counterparty whole, pay them out-of-band (the amount owed is +`amount_sats - node_share_sats`) and keep the row as the audit record. +Before the window elapses, prefer the built-in path: have the +counterparty resend their payout invoice, which flips the row back to +`pending-payout` automatically. + +### Public exposure + +The node advertises its bond policy in the kind-38385 info event +(`src/nip33.rs::info_to_tags`) so clients can warn users before they +trade: `bond_enabled` (always emitted), and when enabled `bond_apply_to`, +`bond_amount_pct`, `bond_base_amount_sats`, `bond_slash_on_waiting_timeout`, +`bond_slash_node_share_pct`, and `bond_payout_claim_window_days`. + ## Diagrams ```mermaid flowchart TD diff --git a/docs/README.md b/docs/README.md index 5d2cded2..f27895a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Quick links to architecture and feature guides. - Lightning Operations: LIGHTNING_OPS.md - Orders & Actions: ORDERS_AND_ACTIONS.md - Admin RPC & Disputes: ADMIN_RPC_AND_DISPUTES.md +- Anti-Abuse Bond: ANTI_ABUSE_BOND.md (opt-in maker/taker Lightning bond; off by default) - RPC Interface Reference: RPC.md - NIP-01 Kind 0 Metadata: NIP01_KIND0_METADATA.md From 14299b1c60454dab0eeb8fa01619f815d236f342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Tue, 16 Jun 2026 16:28:53 +0200 Subject: [PATCH 11/23] =?UTF-8?q?feat(price):=20Phase=202=20=E2=80=94=20di?= =?UTF-8?q?rect=20backup=20quoters=20+=20multi-source=20aggregation=20(#77?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * fix(price): cover the full mirror sequence in the poll budget + fresh breaker timestamps Address PR #773 review findings: - Codex P2 (valid): the outer per-provider timeout equalled the reqwest per-request timeout, so a *hanging* currency_api primary consumed the whole budget and the fallback_urls were dead code in exactly the hung case (they only worked for fast failures like connection-refused). New `poll_budget(id)` sizes the outer tokio timeout to provider_timeout_seconds × (1 + fallback_urls) + 1s slack; the per-attempt bound remains the shared reqwest client's request timeout. Adds a manager unit test for the scaling and an adapter-level axum test proving a hung primary is cut at the request timeout and the mirror still answers. - CodeRabbit Major (valid, minor impact): record_failure reused the pre-poll `now`, so breaker cooldowns were born already aged by up to a full poll budget. Failures are now stamped after polling completes. - CodeRabbit nit (declined): tracing spans around the polling — the codebase uses zero spans anywhere; per-provider outcomes already log the provider id inline, so spans here would be inconsistent house style for little correlation gain. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + Cargo.toml | 1 + docs/PRICE_PROVIDERS.md | 6 +- settings.tpl.toml | 28 +- src/price/fiat.rs | 117 ++++ src/price/manager.rs | 545 +++++++++++++++--- src/price/mod.rs | 17 +- src/price/providers/blockchain.rs | 152 +++++ src/price/providers/coingecko.rs | 205 +++++++ src/price/providers/currency_api.rs | 311 ++++++++++ src/price/providers/mod.rs | 3 + tests/fixtures/price/blockchain_ticker.json | 1 + .../price/coingecko_simple_price.json | 1 + tests/fixtures/price/currency_api_btc.json | 1 + 14 files changed, 1307 insertions(+), 82 deletions(-) create mode 100644 src/price/fiat.rs create mode 100644 src/price/providers/blockchain.rs create mode 100644 src/price/providers/coingecko.rs create mode 100644 src/price/providers/currency_api.rs create mode 100644 tests/fixtures/price/blockchain_ticker.json create mode 100644 tests/fixtures/price/coingecko_simple_price.json create mode 100644 tests/fixtures/price/currency_api_btc.json diff --git a/Cargo.lock b/Cargo.lock index 782a2976..376c1b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1744,6 +1744,7 @@ dependencies = [ "dotenvy", "easy-hasher", "fedimint-tonic-lnd", + "futures", "lightning-invoice", "lnurl-rs", "mostro-core", diff --git a/Cargo.toml b/Cargo.toml index 3991d5c4..1aebca48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ mostro-core = { version = "0.12.1", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } async-trait = "0.1.83" +futures = "0.3.31" clap = { version = "4.5.45", features = ["derive"] } lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] } once_cell = "1.20.2" diff --git a/docs/PRICE_PROVIDERS.md b/docs/PRICE_PROVIDERS.md index 2c230a50..d07ece81 100644 --- a/docs/PRICE_PROVIDERS.md +++ b/docs/PRICE_PROVIDERS.md @@ -471,9 +471,9 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (§6.6) | Phase | PR scope | Depends on | Status | |------:|----------|------------|--------| -| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | — | pending | -| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | pending | -| 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) → real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | pending | +| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | — | done (PR #753) | +| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | done (PR #753) | +| 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) → real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | in review | | 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | pending | | 4 | Unify `get_market_quote` onto the cache; staleness TTL enforcement (`PriceTooStale`) at create/take | 2 | pending | | 5 | Nostr aggregated publishing + token/paid-provider support polish + info-event exposure + retire `bitcoin_price.rs` + ops docs | 3, 4 | pending | diff --git a/settings.tpl.toml b/settings.tpl.toml index b0370fa5..ccef384d 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -122,9 +122,31 @@ port = 50051 # enabled = true # url = "https://api.yadio.io" # -# # The keyless backups, El Toque, etc. are wired in Phases 2 and 3 — see -# # docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for the -# # `only` / `except` per-provider currency scoping rules. +# [price.providers.coingecko] +# enabled = true +# url = "https://api.coingecko.com/api/v3" +# # api_key = "CG-xxxx" # optional demo/pro key; raises rate limits. Pro +# # plans use url = "https://pro-api.coingecko.com/api/v3" (the adapter picks +# # the matching auth header from the host). +# +# # Keyless, CDN-hosted, 300+ currencies incl. CUP at the OFFICIAL rate — so +# # CUP/MLC are excluded to avoid mixing official with the informal-market +# # sources (docs/PRICE_PROVIDERS.md §6.6). +# [price.providers.currency_api] +# enabled = true +# url = "https://currency-api.pages.dev/v1" +# # Ordered mirrors tried in sequence when `url` fails this tick. +# fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"] +# except = ["CUP", "MLC"] +# +# # Keyless, ~28 major fiats, no CUP/MLC. Mid-market (`last`) only. +# [price.providers.blockchain] +# enabled = true +# url = "https://blockchain.info" +# +# # El Toque (fiat-cross CUP/MLC, requires a token) is wired in Phase 3 — +# # see docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for +# # the `only` / `except` per-provider currency scoping rules. # Anti-abuse bond (issue #711). Opt-in, disabled by default. Uncomment to # require a Lightning hold-invoice bond from takers and/or makers. See diff --git a/src/price/fiat.rs b/src/price/fiat.rs new file mode 100644 index 00000000..68af18ca --- /dev/null +++ b/src/price/fiat.rs @@ -0,0 +1,117 @@ +//! The §6.6 fiat allowlist. +//! +//! Providers return junk for our purposes — `currency-api` ships **324+** +//! entries including crypto (`eth`, `bnb`, `ada`) and non-ISO codes, and +//! Yadio quotes metals (`XAU`, `XAG`, `XPT`) and BTC itself. The aggregator +//! restricts to the known fiat set below; everything else is dropped before +//! aggregation and before the Nostr publish, keeping the store and the +//! kind-30078 event lean (spec §6.6). +//! +//! The set is **ISO-4217 active codes** plus the non-ISO codes that are +//! nonetheless real, Mostro-traded fiat — all of them present in Yadio's +//! live feed today: +//! +//! - `IRT` — Iranian toman (the everyday unit; `IRR` is the official rial), +//! - `GGP` / `IMP` / `JEP` — Guernsey / Manx / Jersey pounds (GBP-pegged), +//! - `MLC` — Cuban MLC (the spec's motivating fiat-cross case, §11.3). +//! +//! Deliberately **excluded**: `VEF` (the pre-redenomination Venezuelan +//! code some APIs still report) — its scale diverges from the ISO `VES` by +//! orders of magnitude, so letting it in could price an order with a +//! garbage rate. Same reasoning as scoping out `currency-api`'s official +//! CUP: a different unit is worse than no data. +//! +//! Against the live Yadio feed this drops exactly `BTC`, `XAU`, `XAG`, +//! `XPT` — the non-fiat tail — and nothing else, so Phase 2 does not +//! silently remove a currency a node was publishing yesterday. + +/// Sorted (binary-searchable) allowlist. Keep alphabetical — there is a +/// test asserting the order so `binary_search` stays correct. +const KNOWN_FIAT: &[&str] = &[ + "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", + "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", + "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", + "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF", + "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "IMP", "INR", "IQD", "IRR", "IRT", + "ISK", "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", + "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MLC", "MMK", + "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", + "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", + "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", + "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", + "UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "VUV", "WST", "XAF", "XCD", "XCG", "XOF", + "XPF", "YER", "ZAR", "ZMW", "ZWL", +]; + +/// Whether `code` (any casing) is a known fiat currency. Non-fiat codes are +/// dropped at the manager boundary before aggregation (spec §6.6). +pub fn is_known_fiat(code: &str) -> bool { + let upper = code.to_uppercase(); + KNOWN_FIAT.binary_search(&upper.as_str()).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlist_is_sorted_and_deduped() { + // binary_search precondition; a mis-sorted insert would silently + // make some legitimate currency "unknown". + let mut sorted = KNOWN_FIAT.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(KNOWN_FIAT, sorted.as_slice()); + } + + #[test] + fn accepts_major_and_motivating_codes() { + for code in ["USD", "EUR", "ARS", "CUP", "MLC", "IRT", "VES", "JPY"] { + assert!(is_known_fiat(code), "{code} must be known fiat"); + } + // Case-insensitive: currency-api ships lowercase. + assert!(is_known_fiat("usd")); + assert!(is_known_fiat("cup")); + } + + #[test] + fn rejects_crypto_metals_and_junk() { + // The §6.6 motivating junk: crypto from currency-api, metals and + // the BTC self-quote from Yadio. + // `VEF` is rejected on purpose: pre-redenomination unit, not `VES`. + for code in [ + "BTC", "ETH", "BNB", "ADA", "XAU", "XAG", "XPT", "1INCH", "VEF", "", + ] { + assert!(!is_known_fiat(code), "{code} must be rejected"); + } + } + + #[test] + fn live_yadio_codes_survive_except_non_fiat() { + // Captured from the live Yadio feed 2026-06-11 (128 codes). The + // allowlist must pass every one of them except the four non-fiat + // entries — Phase 2 must not silently drop a currency nodes were + // publishing yesterday. + let yadio_live = [ + "AED", "ALL", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", + "BHD", "BIF", "BMD", "BOB", "BRL", "BSD", "BTC", "BTN", "BWP", "BYN", "BZD", "CAD", + "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", + "DZD", "EGP", "ERN", "ETB", "EUR", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", + "GNF", "GTQ", "HKD", "HNL", "HUF", "IDR", "ILS", "IMP", "INR", "IRR", "IRT", "ISK", + "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KMF", "KRW", "KYD", "KZT", "LBP", "LKR", + "LSL", "MAD", "MGA", "MLC", "MOP", "MRU", "MWK", "MXN", "MYR", "NAD", "NGN", "NIO", + "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", + "RSD", "RUB", "RWF", "SAR", "SEK", "SGD", "SHP", "SYP", "SZL", "THB", "TMT", "TND", + "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "XAF", + "XAG", "XAU", "XCD", "XCG", "XOF", "XPT", "ZAR", "ZMW", + ]; + let non_fiat = ["BTC", "XAU", "XAG", "XPT"]; + for code in yadio_live { + if non_fiat.contains(&code) { + assert!(!is_known_fiat(code), "{code} is non-fiat, must drop"); + } else { + assert!(is_known_fiat(code), "{code} from live Yadio must pass"); + } + } + } +} diff --git a/src/price/manager.rs b/src/price/manager.rs index 01215456..919a1586 100644 --- a/src/price/manager.rs +++ b/src/price/manager.rs @@ -7,43 +7,61 @@ //! providers, aggregate, and write the store; consumers (`get_bitcoin_price`, //! `BitcoinPriceManager::get_price`) read through [`PriceManager::get_price`]. //! -//! ## Phase 1 invariants (spec §9 Phase 1) -//! - The registry is built from `[price]`; only Yadio is wired here, the -//! keyless backups land in Phase 2. +//! ## Phase 1 / 2 invariants (spec §9) +//! - The registry is built from `[price]`; the direct quoters (Yadio, +//! CoinGecko, currency-api, Blockchain) are wired, El Toque lands in +//! Phase 3. //! - Staleness is **logged, not enforced**: a value older than one //! `update_interval` emits a `warn!` but still returns to the caller, so -//! Phase 1 never refuses an order that would have priced today. +//! Phases 1–3 never refuse an order that would have priced today. //! Enforcement turns on in Phase 4. //! - Per-provider failures are isolated: a failed poll contributes nothing //! this tick and the store's last-known-good value is preserved (spec -//! §6.4). The full circuit breaker integration lands in Phase 2. +//! §6.4). Providers are polled **concurrently**, each bounded by +//! `provider_timeout_seconds`, and repeated failures open a per-provider +//! circuit breaker with exponential-backoff cooldown (spec §6.5). +//! - The §6.6 pipeline glue (fiat allowlist + per-provider `only`/`except` +//! scoping) runs at the manager boundary, keeping `aggregate_tick` +//! purely numeric. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::Duration; use chrono::Utc; use mostro_core::error::{MostroError, ServiceError}; use nostr_sdk::prelude::*; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; use super::aggregate::{aggregate_tick, AggregateResult}; use super::config::{PriceSettings, ProviderConfig}; -use super::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes}; +use super::fiat::is_known_fiat; +use super::provider::{PriceProvider, ProviderError, ProviderHealth, ProviderId, ProviderQuotes}; +use super::providers::blockchain::BlockchainProvider; +use super::providers::coingecko::CoinGeckoProvider; +use super::providers::currency_api::CurrencyApiProvider; use super::providers::yadio::YadioProvider; use super::store::{PriceError, PriceStore}; +/// Hard cap on the circuit breaker's exponential-backoff cooldown +/// (spec §6.5: backs off from `provider_failure_cooldown_seconds` "up to a +/// cap (default 1800)"). Not configurable — a provider that has been down +/// for a while should still be re-probed at least every 30 minutes. +const PROVIDER_COOLDOWN_CAP_SECONDS: u64 = 1800; + /// Process-wide singleton. Initialized once in `main` after settings load, /// then read by the scheduler (`update_all`) and consumers (`get_price`). /// Modelled on `MOSTRO_CONFIG`: `OnceLock` so initialization is panic-free /// and tests that never call `init_global` see `None`. static PRICE_MANAGER: OnceLock = OnceLock::new(); -/// One enabled provider plus its registry metadata. Health tracking goes -/// here in Phase 2 — Phase 1 only needs the box. +/// One enabled provider plus its registry metadata and circuit-breaker +/// state (spec §6.5). `health` is a `Mutex` (not `RwLock`) because every +/// access mutates; contention is nil — one scheduler tick at a time. struct EnabledProvider { id: ProviderId, provider: Box, + health: Mutex, } /// Outer `Result` is from [`tokio::time::timeout`] (Elapsed = timed out), @@ -84,7 +102,11 @@ impl PriceManager { match id_str.parse::() { Ok(id) => { let provider = build_provider(id, cfg)?; - providers.push(EnabledProvider { id, provider }); + providers.push(EnabledProvider { + id, + provider, + health: Mutex::new(ProviderHealth::new()), + }); } Err(_) => { warn!( @@ -132,14 +154,15 @@ impl PriceManager { &self.settings } - /// One scheduler tick: poll all enabled providers concurrently with a - /// per-provider timeout, aggregate, and write the store - /// (spec §5.3 steps 1–3). A failed/timed-out provider contributes - /// nothing — the store's prior values for its currencies survive as - /// last-known-good (spec §6.4). + /// One scheduler tick: poll all enabled, breaker-available providers + /// **concurrently** — each fetch bounded by `provider_timeout_seconds` + /// — then aggregate and write the store (spec §5.3 steps 1–3). A + /// failed/timed-out provider contributes nothing — the store's prior + /// values for its currencies survive as last-known-good (spec §6.4) — + /// and counts against its circuit breaker (spec §6.5). /// - /// Returns the per-provider outcome so the scheduler / Phase 2 circuit - /// breaker can act on it. Phase 1 only logs it. + /// Returns the per-provider outcome so the scheduler can log outage + /// transitions. pub async fn update_all(&self) -> TickReport { let mut report = TickReport::default(); if self.providers.is_empty() { @@ -147,51 +170,110 @@ impl PriceManager { return report; } - // Phase 1 only wires Yadio, so per-provider parallelism does not - // change wall-clock time yet; each fetch is awaited in sequence - // with its own [`tokio::time::timeout`] guard so one hanging API - // can't block the tick beyond `provider_timeout_seconds`. Phase 2 - // (multiple direct quoters) replaces this with a concurrent driver - // alongside the circuit-breaker integration (spec §6.5). - let timeout = Duration::from_secs(self.settings.provider_timeout_seconds); - let mut outcomes: Vec<(ProviderId, TimeoutResult)> = - Vec::with_capacity(self.providers.len()); + // Circuit breaker (spec §6.5): a provider in cooldown is skipped + // outright — no request, no log spam, no tick slow-down. A poisoned + // health lock degrades to "available": polling a sick provider too + // often is the safer failure mode (worst case: log noise), whereas + // never polling again would silently amputate a source. + let now = Utc::now().timestamp(); + let mut pollable: Vec<&EnabledProvider> = Vec::with_capacity(self.providers.len()); for p in &self.providers { - let res = tokio::time::timeout(timeout, p.provider.fetch(&self.http)).await; - outcomes.push((p.id, res)); + let available = p.health.lock().map(|h| h.is_available(now)).unwrap_or(true); + if available { + pollable.push(p); + } else { + info!("price: {} skipped: cooldown (circuit breaker open)", p.id); + report.skipped.push(p.id); + } } + // Poll concurrently (spec §5.3 "poll all healthy providers, in + // parallel"): the tick's wall-clock is the slowest single provider, + // never the sum. Each fetch carries its own [`tokio::time::timeout`] + // sized by [`Self::poll_budget`] — the *per-attempt* bound is the + // shared `reqwest` client's request timeout (`from_settings`), while + // this outer budget covers the provider's whole mirror sequence, so + // a hanging primary cannot starve its `fallback_urls` (they'd be + // dead code in exactly the hung case otherwise). + let outcomes: Vec<(ProviderId, TimeoutResult)> = + futures::future::join_all(pollable.iter().map(|p| async move { + let res = + tokio::time::timeout(self.poll_budget(p.id), p.provider.fetch(&self.http)) + .await; + (p.id, res) + })) + .await; + let mut quotes_by_provider: Vec<(ProviderId, ProviderQuotes)> = - Vec::with_capacity(self.providers.len()); - for (id, outcome) in outcomes { - match outcome { + Vec::with_capacity(pollable.len()); + // Re-stamp the clock: the polls above may have consumed up to a full + // poll budget, and a breaker cooldown anchored at the *pre-poll* + // `now` would be born already partially expired — weakening the + // skip exactly when a slow-failing provider needs it most. + let failed_at = Utc::now().timestamp(); + // `join_all` preserves input order, so `pollable[i]` is the provider + // behind `outcomes[i]` — zip them to feed the breaker. + for (p, (id, outcome)) in pollable.iter().zip(outcomes) { + let ok = match outcome { Ok(Ok(quotes)) => { info!("price: {} ok ({} currencies)", id, quotes.len()); quotes_by_provider.push((id, quotes)); report.successes.push(id); + true } Ok(Err(e)) => { warn!("price: {} error: {}", id, e); report.failures.push((id, e.to_string())); + false } Err(_) => { warn!( - "price: {} timed out after {}s", - id, self.settings.provider_timeout_seconds + "price: {} timed out after {}s (full mirror budget)", + id, + self.poll_budget(id).as_secs() ); report.failures.push((id, "timeout".to_string())); + false + } + }; + if let Ok(mut health) = p.health.lock() { + if ok { + health.record_success(); + } else { + health.record_failure( + failed_at, + self.settings.provider_failure_threshold, + self.settings.provider_failure_cooldown_seconds, + PROVIDER_COOLDOWN_CAP_SECONDS, + ); } } } - // Apply per-provider currency scoping (spec §6.6) before - // aggregation. The scoping rules are configured per - // [price.providers.]; the Phase 2 §6.6 pipeline glue (fiat - // allowlist, etc.) layers on top of this. Doing the filter here - // keeps `aggregate_tick` purely numeric. + // §6.6 pipeline glue, at the manager boundary so `aggregate_tick` + // stays purely numeric: + // 1. fiat allowlist — drop crypto/metals/non-ISO junk (e.g. + // currency-api's `eth`, Yadio's `XAU`) before they can form + // single-source aggregates or bloat the Nostr event; + // 2. per-provider `only`/`except` scoping — a mis-marketed source + // (currency-api's official-rate CUP) never enters the median. let filtered_with_ids: Vec<(ProviderId, ProviderQuotes)> = quotes_by_provider .into_iter() - .map(|(id, quotes)| (id, self.scope_quotes(id, quotes))) + .map(|(id, quotes)| { + let before = quotes.len(); + let fiat_only: ProviderQuotes = quotes + .into_iter() + .filter(|(code, _)| is_known_fiat(code)) + .collect(); + let dropped = before - fiat_only.len(); + if dropped > 0 { + debug!( + "price: {} dropped {} non-fiat codes (allowlist)", + id, dropped + ); + } + (id, self.scope_quotes(id, fiat_only)) + }) .collect(); let aggregates = aggregate_tick(&filtered_with_ids, self.settings.outlier_threshold_pct); @@ -227,6 +309,35 @@ impl PriceManager { report } + /// Wall-clock budget for one provider's poll: `provider_timeout_seconds` + /// times the number of URLs the provider may try (primary + + /// `fallback_urls`), plus one second of slack for inter-attempt + /// overhead. + /// + /// Layering: the **per-attempt** bound is enforced by the shared + /// `reqwest` client (`from_settings` sets + /// `.timeout(provider_timeout_seconds)`), so a hung mirror burns one + /// slot of this budget, not all of it. Sizing the outer + /// [`tokio::time::timeout`] to the whole sequence keeps the §7 + /// "mirrors tried in sequence" promise alive in the hung-primary case — + /// with a flat budget the fallbacks were dead code precisely when the + /// primary hung rather than refused (Codex review on PR #773). + fn poll_budget(&self, id: ProviderId) -> Duration { + let attempts = self + .settings + .providers + .get(&id.to_string()) + .map(|c| 1 + c.fallback_urls.len() as u64) + .unwrap_or(1) + .max(1); + Duration::from_secs( + self.settings + .provider_timeout_seconds + .saturating_mul(attempts) + .saturating_add(1), + ) + } + /// Apply this provider's `only`/`except` filter (spec §6.6). Done at the /// manager boundary so the aggregator stays provider-agnostic. fn scope_quotes(&self, id: ProviderId, quotes: ProviderQuotes) -> ProviderQuotes { @@ -446,13 +557,12 @@ fn sources_to_tag(ids: &[ProviderId]) -> String { fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Result, String> { match id { ProviderId::Yadio => Ok(Box::new(YadioProvider::new(cfg))), - // Other adapters land in their own phases (CoinGecko/currency_api/ - // Blockchain → Phase 2, El Toque → Phase 3). Reject explicitly so - // an over-eager config doesn't silently spawn nothing. - ProviderId::CoinGecko - | ProviderId::CurrencyApi - | ProviderId::Blockchain - | ProviderId::ElToque => Err(format!( + ProviderId::CoinGecko => Ok(Box::new(CoinGeckoProvider::new(cfg))), + ProviderId::CurrencyApi => Ok(Box::new(CurrencyApiProvider::new(cfg))), + ProviderId::Blockchain => Ok(Box::new(BlockchainProvider::new(cfg))), + // El Toque lands in Phase 3. Reject explicitly so an over-eager + // config doesn't silently spawn nothing. + ProviderId::ElToque => Err(format!( "price: provider `{id}` is configured (enabled) but not yet implemented in \ this release — disable it or remove it from `[price.providers]` \ (see docs/PRICE_PROVIDERS.md §7)" @@ -479,14 +589,17 @@ impl std::fmt::Display for InstallError { impl std::error::Error for InstallError {} -/// Per-tick outcome used by the scheduler (for outage logging) and the -/// Phase 2 circuit breaker. +/// Per-tick outcome used by the scheduler (for outage logging) and tests. #[derive(Debug, Default)] pub struct TickReport { /// Providers whose [`PriceProvider::fetch`] returned `Ok` this tick. pub successes: Vec, /// Providers that failed or timed out, with the stringified error. pub failures: Vec<(ProviderId, String)>, + /// Providers not polled because their circuit breaker is in cooldown + /// (spec §6.5). Neither a success nor a failure: the breaker state + /// carries over to the next tick. + pub skipped: Vec, /// Providers whose post-scope quote map was non-empty — i.e. those /// that actually contributed at least one currency to the aggregate. /// Distinct from `successes`: a scoped-out provider lands in @@ -566,7 +679,7 @@ mod tests { } } - fn manager_with(scripted: ScriptedProvider) -> PriceManager { + fn manager_with_many(scripted: Vec) -> PriceManager { // Disable Nostr publishing so tests don't reach the global Nostr // client (which isn't installed in unit tests); short timeout so a // hanging mock can't blow the test runner. @@ -575,23 +688,28 @@ mod tests { provider_timeout_seconds: 5, ..PriceSettings::default() }; - settings.providers.insert( - scripted.id.to_string(), - ProviderConfig { - enabled: true, - url: "http://test".into(), - fallback_urls: vec![], - api_key: None, - token: None, - only: None, - except: None, - }, - ); + let mut providers = Vec::new(); + for s in scripted { + settings.providers.insert( + s.id.to_string(), + ProviderConfig { + enabled: true, + url: "http://test".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + providers.push(EnabledProvider { + id: s.id, + provider: Box::new(s), + health: Mutex::new(ProviderHealth::new()), + }); + } PriceManager { - providers: vec![EnabledProvider { - id: scripted.id, - provider: Box::new(scripted), - }], + providers, store: Arc::new(PriceStore::new()), settings, http: reqwest::Client::new(), @@ -600,6 +718,10 @@ mod tests { } } + fn manager_with(scripted: ScriptedProvider) -> PriceManager { + manager_with_many(vec![scripted]) + } + #[tokio::test] async fn single_yadio_tick_matches_today() { // Spec §9 Phase 1 acceptance: with only Yadio enabled, the manager @@ -710,18 +832,18 @@ mod tests { } #[test] - fn from_settings_rejects_invalid_provider_id() { - // An enabled provider whose adapter isn't yet wired must fail at - // startup, not silently produce nothing. + fn from_settings_rejects_unimplemented_provider_id() { + // An enabled provider whose adapter isn't yet wired (El Toque, + // Phase 3) must fail at startup, not silently produce nothing. let mut settings = PriceSettings::default(); settings.providers.insert( - ProviderId::CoinGecko.to_string(), + ProviderId::ElToque.to_string(), ProviderConfig { enabled: true, - url: "https://api.coingecko.com/api/v3".into(), + url: "https://tasas.eltoque.com".into(), fallback_urls: vec![], api_key: None, - token: None, + token: Some("x".into()), only: None, except: None, }, @@ -729,6 +851,34 @@ mod tests { assert!(PriceManager::from_settings(settings).is_err()); } + #[test] + fn from_settings_builds_all_phase2_providers() { + // Spec §9 Phase 2: the three keyless backups join Yadio in the + // registry — the §7 example config must now build cleanly. + let mut settings = PriceSettings::default(); + for (id, url) in [ + (ProviderId::Yadio, "https://api.yadio.io"), + (ProviderId::CoinGecko, "https://api.coingecko.com/api/v3"), + (ProviderId::CurrencyApi, "https://currency-api.pages.dev/v1"), + (ProviderId::Blockchain, "https://blockchain.info"), + ] { + settings.providers.insert( + id.to_string(), + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + } + let m = PriceManager::from_settings(settings).expect("phase 2 registry builds"); + assert_eq!(m.providers.len(), 4); + } + #[test] fn from_settings_ignores_unknown_id() { // Adding a new provider in a newer release should not break an @@ -805,6 +955,257 @@ mod tests { assert_eq!(report.fresh_currencies, 0); } + fn quotes_of(pairs: &[(&str, f64)]) -> ProviderQuotes { + pairs + .iter() + .map(|(c, v)| (c.to_string(), Quote::PerBtc(*v))) + .collect() + } + + #[tokio::test] + async fn multi_source_aggregate_is_median_plus_outlier_mean() { + // Spec §9 Phase 2 acceptance: EUR/USD aggregate = median + outlier + // across all live direct quoters, and a wild outlier with ≥3 + // sources is discarded. USD candidates {49_500, 50_000, 50_500, + // 80_000}: median 50_250, the 5% band keeps the first three, the + // 80_000 outlier is dropped → mean = 50_000. + let providers = vec![ + ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes_of(&[("USD", 50_000.0), ("EUR", 43_000.0)]))], + ), + ScriptedProvider::new( + ProviderId::CoinGecko, + vec![Ok(quotes_of(&[("USD", 50_500.0), ("EUR", 43_200.0)]))], + ), + ScriptedProvider::new( + ProviderId::Blockchain, + vec![Ok(quotes_of(&[("USD", 49_500.0), ("EUR", 42_800.0)]))], + ), + ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[("USD", 80_000.0)]))], // wild outlier + ), + ]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!(report.successes.len(), 4); + + let usd = manager.get_price("USD").unwrap(); + assert!( + (usd - 50_000.0).abs() < 1e-6, + "outlier must be discarded before the mean, got {usd}" + ); + let eur = manager.get_price("EUR").unwrap(); + assert!( + (eur - 43_000.0).abs() < 1e-6, + "median-anchored mean, got {eur}" + ); + // The outlier provider polled OK but its value did not survive — + // it must not appear in the contributing-source list. + assert!(report.contributors.contains(&ProviderId::Yadio)); + assert!(!report.contributors.contains(&ProviderId::CurrencyApi)); + } + + #[tokio::test] + async fn lowercase_and_uppercase_codes_combine() { + // Spec §9 Phase 2 acceptance: lowercase currency-api codes combine + // with uppercase Yadio codes — the normalisation test that would + // silently fail without §6.6. (Adapters canonicalise; this guards + // the aggregator-side uppercase against a future adapter that + // forgets.) + let providers = vec![ + ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes_of(&[("USD", 50_000.0)]))]), + ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[("usd", 51_000.0)]))], + ), + ]; + let manager = manager_with_many(providers); + manager.update_all().await; + let usd = manager.get_price("USD").unwrap(); + assert!( + (usd - 50_500.0).abs() < 1e-6, + "two casings must form ONE two-source aggregate (mean), got {usd}" + ); + } + + #[tokio::test] + async fn non_fiat_codes_are_dropped_by_allowlist() { + // Spec §9 Phase 2 acceptance: non-fiat codes (`eth`, `bnb`) from + // currency-api are dropped by the allowlist — as are Yadio's + // metals/BTC self-quote. + let providers = vec![ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[ + ("usd", 50_000.0), + ("eth", 37.8), + ("bnb", 150.0), + ("xau", 25.0), + ("btc", 1.0), + ]))], + )]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!( + report.fresh_currencies, 1, + "only USD survives the allowlist" + ); + assert!(manager.get_price("USD").is_ok()); + assert!(manager.get_price("ETH").is_err()); + assert!(manager.get_price("BTC").is_err()); + } + + #[tokio::test] + async fn official_cup_is_scoped_out_by_except() { + // Spec §9 Phase 2 acceptance: currency-api's official-rate CUP is + // scoped out by the shipped `except = ["CUP","MLC"]`, so it never + // enters the CUP aggregate — Yadio's informal rate stands alone. + let providers = vec![ + ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes_of(&[("USD", 50_000.0), ("CUP", 20_000_000.0)]))], + ), + ScriptedProvider::new( + ProviderId::CurrencyApi, + // Official rate: ~26 CUP/USD → 1.3M CUP/BTC — 15× off. + vec![Ok(quotes_of(&[("usd", 50_100.0), ("cup", 1_300_000.0)]))], + ), + ]; + let mut manager = manager_with_many(providers); + manager + .settings + .providers + .get_mut(&ProviderId::CurrencyApi.to_string()) + .unwrap() + .except = Some(vec!["CUP".into(), "MLC".into()]); + + manager.update_all().await; + let cup = manager.get_price("CUP").unwrap(); + assert!( + (cup - 20_000_000.0).abs() < 1e-6, + "official-rate CUP must never enter the aggregate (got {cup}); \ + with only 2 sources the outlier guard cannot save us — scoping must" + ); + // USD still combines from both. + let usd = manager.get_price("USD").unwrap(); + assert!((usd - 50_050.0).abs() < 1e-6); + } + + #[tokio::test] + async fn provider_down_falls_back_to_remaining_sources() { + // Spec §9 Phase 2 acceptance: a provider down → currencies fall + // back to the remaining sources, same tick. + let providers = vec![ + ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes_of(&[("USD", 50_000.0)]))]), + ScriptedProvider::new( + ProviderId::CoinGecko, + vec![Err(ProviderError::Http("down".into()))], + ), + ]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert_eq!(report.failures.len(), 1); + assert!((manager.get_price("USD").unwrap() - 50_000.0).abs() < 1e-6); + } + + #[tokio::test] + async fn circuit_breaker_skips_after_threshold_failures() { + // Spec §9 Phase 2 acceptance: the breaker opens after N consecutive + // failures, and an open breaker means the provider is not even + // polled next tick (skipped, not failed). The cooldown/half-open + // timing math is covered by the pure ProviderHealth unit tests. + let scripted = ScriptedProvider::new( + ProviderId::CoinGecko, + vec![ + Err(ProviderError::Http("down".into())), + // Would succeed if (wrongly) polled while the breaker is open: + Ok(quotes_of(&[("USD", 50_000.0)])), + ], + ); + let mut manager = manager_with(scripted); + manager.settings.provider_failure_threshold = 1; + manager.settings.provider_failure_cooldown_seconds = 3_600; // ≫ test runtime + + let first = manager.update_all().await; + assert_eq!( + first.failures.len(), + 1, + "tick 1: the failure trips the breaker" + ); + assert!(first.skipped.is_empty()); + + let second = manager.update_all().await; + assert_eq!( + second.skipped, + vec![ProviderId::CoinGecko], + "tick 2: open breaker → skipped without polling" + ); + assert!( + second.successes.is_empty(), + "the scripted Ok was never consumed" + ); + assert!(second.failures.is_empty(), "skipped ≠ failed"); + } + + #[test] + fn poll_budget_scales_with_fallback_urls() { + // The outer per-provider timeout must cover the whole mirror + // sequence (primary + fallbacks), or a hung primary starves the + // mirrors (Codex review on PR #773). Per-attempt bounding is the + // shared reqwest client's job. + let scripted = ScriptedProvider::new(ProviderId::CurrencyApi, vec![]); + let mut manager = manager_with(scripted); + manager.settings.provider_timeout_seconds = 10; + + // No fallbacks: one attempt + 1s slack. + assert_eq!( + manager.poll_budget(ProviderId::CurrencyApi), + Duration::from_secs(11) + ); + // Two mirrors: three attempts + slack. + manager + .settings + .providers + .get_mut(&ProviderId::CurrencyApi.to_string()) + .unwrap() + .fallback_urls = vec!["http://m1".into(), "http://m2".into()]; + assert_eq!( + manager.poll_budget(ProviderId::CurrencyApi), + Duration::from_secs(31) + ); + // Unknown id (defensive): single-attempt budget. + assert_eq!( + manager.poll_budget(ProviderId::Blockchain), + Duration::from_secs(11) + ); + } + + #[tokio::test] + async fn breaker_success_after_cooldown_resets() { + // With a zero-second cooldown the breaker re-probes immediately; + // a success must reset it (no skip on the following tick). + let scripted = ScriptedProvider::new( + ProviderId::CoinGecko, + vec![ + Err(ProviderError::Http("down".into())), + Ok(quotes_of(&[("USD", 50_000.0)])), + Ok(quotes_of(&[("USD", 50_100.0)])), + ], + ); + let mut manager = manager_with(scripted); + manager.settings.provider_failure_threshold = 1; + manager.settings.provider_failure_cooldown_seconds = 0; // immediate re-probe + + manager.update_all().await; // fails, opens (0s cooldown) + let second = manager.update_all().await; // re-probe succeeds → reset + assert_eq!(second.successes, vec![ProviderId::CoinGecko]); + let third = manager.update_all().await; + assert_eq!(third.successes, vec![ProviderId::CoinGecko]); + assert!(third.skipped.is_empty()); + } + #[tokio::test] async fn stale_warning_is_one_shot_then_re_arms_on_fresh_read() { // Build a manager whose only stored value is intentionally past diff --git a/src/price/mod.rs b/src/price/mod.rs index 4c6bf8eb..2ff1e677 100644 --- a/src/price/mod.rs +++ b/src/price/mod.rs @@ -1,17 +1,26 @@ //! Multi-source BTC/fiat price module (see `docs/PRICE_PROVIDERS.md`). //! //! ## Phase 1 -//! The module is now wired into the daemon: [`PriceManager`] builds the +//! The module is wired into the daemon: [`PriceManager`] builds the //! provider registry from `[price]` (or a legacy migration when the //! section is absent, spec §10.1), the scheduler drives //! [`PriceManager::update_all`] every `update_interval_seconds`, and //! [`get_bitcoin_price`] / `BitcoinPriceManager::get_price` read through -//! the manager. The only adapter wired so far is [`providers::yadio`]; -//! the keyless backups (CoinGecko, currency-api, Blockchain) land in -//! Phase 2, and El Toque in Phase 3. +//! the manager. +//! +//! ## Phase 2 +//! The system is genuinely multi-source: the keyless direct backups +//! ([`providers::coingecko`], [`providers::currency_api`], +//! [`providers::blockchain`]) join [`providers::yadio`]; providers are +//! polled **concurrently** with a per-provider timeout and circuit +//! breaker (spec §6.5), and the §6.6 pipeline glue (code canonicalisation, +//! the [`fiat`] allowlist, per-provider `only`/`except` scoping) sits +//! between the adapters and the aggregation core. El Toque lands in +//! Phase 3. pub mod aggregate; pub mod config; +pub mod fiat; pub mod manager; pub mod provider; pub mod providers; diff --git a/src/price/providers/blockchain.rs b/src/price/providers/blockchain.rs new file mode 100644 index 00000000..db41f02c --- /dev/null +++ b/src/price/providers/blockchain.rs @@ -0,0 +1,152 @@ +//! Blockchain.com direct BTC quoter (spec §11.4). +//! +//! Calls `GET {url}/ticker` and maps the +//! `{ "USD": { "15m": …, "last": …, "buy": …, "sell": …, "symbol": "USD" } }` +//! body into per-currency [`Quote::PerBtc`] entries. The adapter takes +//! **`last`** (mid-market) and discards `buy`/`sell` — Mostro prices at +//! mid-market and never bakes in an exchange spread (§6.6, §11.6); the +//! order premium/fee is the only markup, applied downstream. +//! +//! Keyless, ~28 major fiats, no CUP/MLC — a redundancy anchor for +//! USD/EUR/GBP/JPY, not a long-tail source. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// One ticker entry. Only `last` (mid-market) is used; the bid/ask fields +/// are intentionally not even deserialised so a future refactor cannot +/// accidentally reach for them (§6.6 mid-market rule). +#[derive(Debug, Deserialize)] +struct TickerEntry { + last: Option, +} + +/// Direct BTC quoter against the Blockchain.com ticker. +#[derive(Debug)] +pub struct BlockchainProvider { + url: String, +} + +impl BlockchainProvider { + /// Build the provider from its `[price.providers.blockchain]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + } + } + + /// Parse a `/ticker` payload into [`ProviderQuotes`]. Split out from + /// [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec §10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: HashMap = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("blockchain: {e}")))?; + Ok(parsed + .into_iter() + .filter_map(|(code, entry)| match entry.last { + Some(v) if v.is_finite() && v > 0.0 => { + // Codes arrive uppercase already; canonicalise anyway so + // the adapter honours §6.6 even if the API drifts. + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for BlockchainProvider { + fn id(&self) -> ProviderId { + ProviderId::Blockchain + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/ticker", self.url); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("blockchain GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "blockchain GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("blockchain read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/blockchain_ticker.json"); + + #[test] + fn parses_captured_payload_taking_last() { + let quotes = BlockchainProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11: ~28 majors, uppercase codes. + assert!(quotes.len() >= 20, "expected the major-fiat set"); + assert!(quotes.contains_key("USD")); + assert!(quotes.contains_key("EUR")); + assert!(quotes.contains_key("JPY")); + // No CUP/MLC (§11.4). + assert!(!quotes.contains_key("CUP")); + assert!(!quotes.contains_key("MLC")); + } + + #[test] + fn takes_last_not_buy_or_sell() { + // `last` is mid-market; buy/sell carry the exchange spread and must + // be ignored (§6.6 / §11.6 — the BTCPay contrast). + let body = r#"{"USD": {"15m": 1.0, "last": 50000.0, "buy": 49000.0, "sell": 51000.0, "symbol": "USD"}}"#; + let quotes = BlockchainProvider::parse(body).unwrap(); + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(50_000.0))); + } + + #[test] + fn drops_missing_and_non_positive_last() { + let body = r#"{ + "AAA": {"15m": 1.0, "buy": 1.0, "sell": 1.0, "symbol": "AAA"}, + "BBB": {"last": 0, "symbol": "BBB"}, + "GBP": {"last": 47293.0, "symbol": "GBP"} + }"#; + let quotes = BlockchainProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1, "only GBP has a usable `last`"); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_293.0))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + BlockchainProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn new_strips_trailing_slash() { + let cfg = ProviderConfig { + enabled: true, + url: "https://blockchain.info/".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }; + assert_eq!(BlockchainProvider::new(&cfg).url, "https://blockchain.info"); + } +} diff --git a/src/price/providers/coingecko.rs b/src/price/providers/coingecko.rs new file mode 100644 index 00000000..66dec104 --- /dev/null +++ b/src/price/providers/coingecko.rs @@ -0,0 +1,205 @@ +//! CoinGecko direct BTC quoter (spec §11.2). +//! +//! Calls `GET {url}/simple/price?ids=bitcoin&vs_currencies=` and maps +//! the `{ "bitcoin": { ccy: price } }` body into per-currency +//! [`Quote::PerBtc`] entries. CoinGecko ships lowercase codes; the adapter +//! upper-cases them so they combine with Yadio/Blockchain quotes (spec §6.6). +//! +//! The keyless tier is rate-limited; an optional `api_key` (demo or pro) +//! raises the limits. The key is sent as the appropriate header — CoinGecko +//! pro keys go to `pro-api.coingecko.com` with `x-cg-pro-api-key`, demo keys +//! to the public host with `x-cg-demo-api-key`; we pick the header from the +//! configured URL so one config field serves both plans. Per spec §10.3 the +//! key never appears in logs or `Debug` output. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// The fiat subset of CoinGecko's `supported_vs_currencies`, baked so one +/// request covers everything we can use (the endpoint requires an explicit +/// list). Codes CoinGecko does not recognise are silently omitted from the +/// response, so this list ages safely; CUP/MLC are absent because CoinGecko +/// does not list them (spec §11.2). `vef` (which CoinGecko still supports) +/// is deliberately not requested — it is the pre-redenomination Venezuelan +/// code, a different unit from the ISO `VES` (see `price::fiat`). +const VS_CURRENCIES: &str = "usd,eur,gbp,jpy,ars,aud,bdt,bhd,bmd,brl,cad,chf,\ + clp,cny,czk,dkk,gel,hkd,huf,idr,ils,inr,krw,kwd,\ + lkr,mmk,mxn,myr,ngn,nok,nzd,php,pkr,pln,rub,sar,\ + sek,sgd,thb,try,twd,uah,vnd,zar"; + +/// Response shape: `{ "bitcoin": { "usd": 63410, ... } }`. Lenient on the +/// value (`Option`) so one `null` rate cannot fail the whole poll. +#[derive(Debug, Deserialize)] +struct CoinGeckoResponse { + bitcoin: HashMap>, +} + +/// Direct BTC quoter against the CoinGecko API. +pub struct CoinGeckoProvider { + url: String, + api_key: Option, +} + +// Manual impl so the API key can never leak through `{:?}` logging +// (spec §10.3 redaction requirement). +impl std::fmt::Debug for CoinGeckoProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoinGeckoProvider") + .field("url", &self.url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .finish() + } +} + +impl CoinGeckoProvider { + /// Build the provider from its `[price.providers.coingecko]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + api_key: cfg.api_key.clone(), + } + } + + /// Header name for the configured key: pro keys are only valid against + /// the `pro-api` host, demo keys against the public one. + fn api_key_header(&self) -> &'static str { + if self.url.contains("pro-api") { + "x-cg-pro-api-key" + } else { + "x-cg-demo-api-key" + } + } + + /// Parse a `/simple/price` payload into [`ProviderQuotes`]. Split out + /// from [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec §10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: CoinGeckoResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("coingecko: {e}")))?; + Ok(parsed + .bitcoin + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => { + // CoinGecko ships lowercase codes — canonicalise (§6.6). + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for CoinGeckoProvider { + fn id(&self) -> ProviderId { + ProviderId::CoinGecko + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!( + "{}/simple/price?ids=bitcoin&vs_currencies={}", + self.url, VS_CURRENCIES + ); + let mut req = http.get(&url); + if let Some(key) = &self.api_key { + req = req.header(self.api_key_header(), key); + } + let res = req + .send() + .await + .map_err(|e| ProviderError::Http(format!("coingecko GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "coingecko GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("coingecko read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/coingecko_simple_price.json"); + + fn cfg(url: &str, api_key: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: api_key.map(String::from), + token: None, + only: None, + except: None, + } + } + + #[test] + fn parses_captured_payload_and_uppercases_codes() { + let quotes = CoinGeckoProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11; codes arrive lowercase and must be + // canonicalised so they combine with Yadio's uppercase ones (§6.6). + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(63410.0))); + assert_eq!(quotes.get("EUR"), Some(&Quote::PerBtc(54815.0))); + assert_eq!(quotes.get("JPY"), Some(&Quote::PerBtc(10143476.0))); + assert!(!quotes.contains_key("usd"), "no lowercase keys may leak"); + // CoinGecko does not list CUP/MLC (§11.2). + assert!(!quotes.contains_key("CUP")); + } + + #[test] + fn drops_null_and_non_positive() { + let body = r#"{"bitcoin": {"usd": null, "eur": -5, "gbp": 47293.0}}"#; + let quotes = CoinGeckoProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_293.0))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + CoinGeckoProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn api_key_header_matches_host() { + let demo = CoinGeckoProvider::new(&cfg("https://api.coingecko.com/api/v3", Some("CG-x"))); + assert_eq!(demo.api_key_header(), "x-cg-demo-api-key"); + let pro = + CoinGeckoProvider::new(&cfg("https://pro-api.coingecko.com/api/v3", Some("CG-x"))); + assert_eq!(pro.api_key_header(), "x-cg-pro-api-key"); + } + + #[test] + fn debug_redacts_api_key() { + // Spec §10.3: the key must never appear in `Debug` output (logs). + let p = CoinGeckoProvider::new(&cfg( + "https://api.coingecko.com/api/v3", + Some("CG-supersecret"), + )); + let dbg = format!("{p:?}"); + assert!(!dbg.contains("supersecret"), "api_key leaked: {dbg}"); + assert!(dbg.contains("")); + } + + #[test] + fn new_strips_trailing_slash() { + let p = CoinGeckoProvider::new(&cfg("https://api.coingecko.com/api/v3/", None)); + assert_eq!(p.url, "https://api.coingecko.com/api/v3"); + } +} diff --git a/src/price/providers/currency_api.rs b/src/price/providers/currency_api.rs new file mode 100644 index 00000000..6e3264f3 --- /dev/null +++ b/src/price/providers/currency_api.rs @@ -0,0 +1,311 @@ +//! currency-api / fawazahmed0 direct BTC quoter (spec §11.5). +//! +//! Calls `GET {url}/currencies/btc.min.json` and maps the +//! `{ "date": "…", "btc": { ccy: price } }` body into per-currency +//! [`Quote::PerBtc`] entries. Two §6.6 caveats are handled *outside* this +//! adapter, by design: +//! +//! - The payload ships **324+ entries including crypto** (`eth`, `bnb`, …) +//! and non-ISO codes — the manager's fiat allowlist drops those before +//! aggregation. The adapter itself stays a faithful map of the API. +//! - Its CUP is the **official** rate (~26 CUP/USD), a different market +//! from Yadio/El Toque's informal rate (~400 CUP/USD) — the shipped +//! config scopes it out with `except = ["CUP", "MLC"]`. +//! +//! Codes arrive **lowercase** and are canonicalised to uppercase here so +//! they combine with Yadio/Blockchain quotes (spec §6.6). +//! +//! The API is CDN-hosted (Cloudflare Pages + a jsdelivr mirror). The +//! adapter implements `fallback_urls` (spec §7): mirrors are tried in +//! order, and the provider only fails the tick when **every** URL fails. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// Response shape: `{ "date": "…", "btc": { "usd": 62519.29, … } }`. +/// Lenient on the value so one `null` cannot fail the whole poll. +#[derive(Debug, Deserialize)] +struct CurrencyApiResponse { + btc: HashMap>, +} + +/// Direct BTC quoter against currency-api with ordered mirror fallback. +#[derive(Debug)] +pub struct CurrencyApiProvider { + urls: Vec, +} + +impl CurrencyApiProvider { + /// Build the provider from its `[price.providers.currency_api]` + /// sub-table. The primary `url` plus every `fallback_urls` entry form + /// the ordered candidate list (spec §7). + pub fn new(cfg: &ProviderConfig) -> Self { + let urls = std::iter::once(&cfg.url) + .chain(cfg.fallback_urls.iter()) + .map(|u| u.trim_end_matches('/').to_string()) + .filter(|u| !u.is_empty()) + .collect(); + Self { urls } + } + + /// Parse a `btc.min.json` payload into [`ProviderQuotes`]. Split out + /// from [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec §10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: CurrencyApiResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("currency_api: {e}")))?; + Ok(parsed + .btc + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => { + // currency-api ships lowercase codes — canonicalise (§6.6). + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } + + /// One attempt against one base URL. + async fn fetch_one( + &self, + http: &reqwest::Client, + base: &str, + ) -> Result { + let url = format!("{base}/currencies/btc.min.json"); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("currency_api GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "currency_api GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("currency_api read body: {e}")))?; + Self::parse(&body) + } +} + +#[async_trait] +impl PriceProvider for CurrencyApiProvider { + fn id(&self) -> ProviderId { + ProviderId::CurrencyApi + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + // Try the primary, then each mirror in order; first success wins. + // Only when every URL fails does the provider fail the tick (and + // count against its circuit breaker) — spec §7 "tried in sequence". + let mut last_err = + ProviderError::Misconfigured("currency_api: no usable url configured".into()); + for base in &self.urls { + match self.fetch_one(http, base).await { + Ok(quotes) => return Ok(quotes), + Err(e) => { + tracing::warn!("price: currency_api mirror {base} failed: {e}"); + last_err = e; + } + } + } + Err(last_err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/currency_api_btc.json"); + + fn cfg(url: &str, fallbacks: Vec) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: fallbacks, + api_key: None, + token: None, + only: None, + except: None, + } + } + + #[test] + fn parses_captured_payload_and_uppercases_codes() { + let quotes = CurrencyApiProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11. Lowercase codes must be canonicalised. + assert!(quotes.contains_key("USD")); + assert!(quotes.contains_key("EUR")); + assert!(!quotes.contains_key("usd"), "no lowercase keys may leak"); + // The raw payload legitimately includes crypto junk (`eth`, `bnb`) + // and the OFFICIAL-rate CUP — the adapter maps them faithfully; + // dropping them is the job of the manager's fiat allowlist and the + // shipped `except = ["CUP","MLC"]` scoping (§6.6). Asserting they + // are present here pins the layering: adapter = faithful map. + assert!(quotes.contains_key("ETH")); + assert!(quotes.contains_key("CUP")); + // The captured CUP is the official rate: CUP/BTC ÷ USD/BTC ≈ 26 + // CUP/USD (the informal market is ~400) — the §11.5 hazard is real. + let cup = match quotes.get("CUP").unwrap() { + Quote::PerBtc(v) => *v, + _ => unreachable!(), + }; + let usd = match quotes.get("USD").unwrap() { + Quote::PerBtc(v) => *v, + _ => unreachable!(), + }; + let cup_per_usd = cup / usd; + assert!( + (20.0..40.0).contains(&cup_per_usd), + "captured CUP should be the official ~26 CUP/USD rate, got {cup_per_usd}" + ); + } + + #[test] + fn drops_null_and_non_positive() { + let body = r#"{"date":"2026-06-11","btc":{"usd":null,"eur":-1,"gbp":47000.5}}"#; + let quotes = CurrencyApiProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_000.5))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + CurrencyApiProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn url_order_is_primary_then_fallbacks() { + let p = CurrencyApiProvider::new(&cfg( + "https://currency-api.pages.dev/v1/", + vec!["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1".into()], + )); + assert_eq!( + p.urls, + vec![ + "https://currency-api.pages.dev/v1", + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1" + ] + ); + } + + /// Spec §9 Phase 2 acceptance: "a provider's `fallback_urls` is tried + /// before the provider is marked failed". The primary URL points at a + /// dead local port (instant connection-refused); the fallback is a real + /// local HTTP server returning the captured fixture. `fetch` must + /// succeed via the mirror. + #[tokio::test] + async fn fallback_url_is_tried_before_failing() { + use axum::{routing::get, Router}; + + let app = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { SAMPLE_PAYLOAD }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let p = CurrencyApiProvider::new(&cfg( + // Port 9 (discard) on localhost: nothing listens, fails fast. + "http://127.0.0.1:9/v1", + vec![format!("http://{addr}/v1")], + )); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let quotes = p.fetch(&http).await.expect("mirror must carry the fetch"); + assert!(quotes.contains_key("USD")); + } + + /// A *hanging* primary (vs the fast connection-refused above) must not + /// starve the mirror: the per-attempt bound is the HTTP client's + /// request timeout, so the hung attempt is cut at ~1s and the mirror + /// still answers within the manager's mirror-sequence budget + /// (`poll_budget`). Guards the Codex finding on PR #773. + #[tokio::test] + async fn hanging_primary_does_not_starve_the_mirror() { + use axum::{routing::get, Router}; + + // Primary: accepts the connection, then stalls far past the client + // request timeout. + let hang = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + SAMPLE_PAYLOAD + }), + ); + let hang_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let hang_addr = hang_listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(hang_listener, hang).await.unwrap(); + }); + + // Mirror: instant fixture. + let ok = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { SAMPLE_PAYLOAD }), + ); + let ok_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let ok_addr = ok_listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(ok_listener, ok).await.unwrap(); + }); + + let p = CurrencyApiProvider::new(&cfg( + &format!("http://{hang_addr}/v1"), + vec![format!("http://{ok_addr}/v1")], + )); + // Mirrors `from_settings`: the client's request timeout IS the + // per-attempt bound. + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build() + .unwrap(); + let started = std::time::Instant::now(); + let quotes = p + .fetch(&http) + .await + .expect("mirror must carry the fetch despite the hung primary"); + assert!(quotes.contains_key("USD")); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "hung primary must be cut by the per-attempt timeout, not ride forever" + ); + } + + #[tokio::test] + async fn all_urls_failing_is_one_provider_error() { + let p = CurrencyApiProvider::new(&cfg( + "http://127.0.0.1:9/v1", + vec!["http://127.0.0.1:9/v2".into()], + )); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert!(matches!( + p.fetch(&http).await.unwrap_err(), + ProviderError::Http(_) + )); + } +} diff --git a/src/price/providers/mod.rs b/src/price/providers/mod.rs index d056f5e6..a86fe7c0 100644 --- a/src/price/providers/mod.rs +++ b/src/price/providers/mod.rs @@ -6,4 +6,7 @@ //! [`super::PriceManager::from_settings`] + one config sub-table (see spec //! §5.4). The aggregation core, store and scheduler are never touched. +pub mod blockchain; +pub mod coingecko; +pub mod currency_api; pub mod yadio; diff --git a/tests/fixtures/price/blockchain_ticker.json b/tests/fixtures/price/blockchain_ticker.json new file mode 100644 index 00000000..9bbd098b --- /dev/null +++ b/tests/fixtures/price/blockchain_ticker.json @@ -0,0 +1 @@ +{"ARS":{"15m":9.083198811E7,"last":9.083198811E7,"buy":9.083198811E7,"sell":9.083198811E7,"symbol":"ARS"},"AUD":{"15m":90098.69,"last":90098.69,"buy":90098.69,"sell":90098.69,"symbol":"AUD"},"BRL":{"15m":325344.93,"last":325344.93,"buy":325344.93,"sell":325344.93,"symbol":"BRL"},"CAD":{"15m":88608.43,"last":88608.43,"buy":88608.43,"sell":88608.43,"symbol":"CAD"},"CHF":{"15m":50480.71,"last":50480.71,"buy":50480.71,"sell":50480.71,"symbol":"CHF"},"CLP":{"15m":5.742869351E7,"last":5.742869351E7,"buy":5.742869351E7,"sell":5.742869351E7,"symbol":"CLP"},"CNY":{"15m":429686.37,"last":429686.37,"buy":429686.37,"sell":429686.37,"symbol":"CNY"},"CZK":{"15m":1325545.33,"last":1325545.33,"buy":1325545.33,"sell":1325545.33,"symbol":"CZK"},"DKK":{"15m":409665.43,"last":409665.43,"buy":409665.43,"sell":409665.43,"symbol":"DKK"},"EUR":{"15m":54815.31,"last":54815.31,"buy":54815.31,"sell":54815.31,"symbol":"EUR"},"GBP":{"15m":47293.02,"last":47293.02,"buy":47293.02,"sell":47293.02,"symbol":"GBP"},"GHS":{"15m":764497.37,"last":764497.37,"buy":764497.37,"sell":764497.37,"symbol":"GHS"},"HKD":{"15m":496968.37,"last":496968.37,"buy":496968.37,"sell":496968.37,"symbol":"HKD"},"HRK":{"15m":292489.89,"last":292489.89,"buy":292489.89,"sell":292489.89,"symbol":"HRK"},"HUF":{"15m":1.936378179E7,"last":1.936378179E7,"buy":1.936378179E7,"sell":1.936378179E7,"symbol":"HUF"},"INR":{"15m":6044349.35,"last":6044349.35,"buy":6044349.35,"sell":6044349.35,"symbol":"INR"},"ISK":{"15m":7884422.35,"last":7884422.35,"buy":7884422.35,"sell":7884422.35,"symbol":"ISK"},"JPY":{"15m":1.014347211E7,"last":1.014347211E7,"buy":1.014347211E7,"sell":1.014347211E7,"symbol":"JPY"},"KRW":{"15m":9.634745798E7,"last":9.634745798E7,"buy":9.634745798E7,"sell":9.634745798E7,"symbol":"KRW"},"NGN":{"15m":8.823351891E7,"last":8.823351891E7,"buy":8.823351891E7,"sell":8.823351891E7,"symbol":"NGN"},"NZD":{"15m":108824.86,"last":108824.86,"buy":108824.86,"sell":108824.86,"symbol":"NZD"},"PLN":{"15m":233235.43,"last":233235.43,"buy":233235.43,"sell":233235.43,"symbol":"PLN"},"RON":{"15m":287197.37,"last":287197.37,"buy":287197.37,"sell":287197.37,"symbol":"RON"},"RUB":{"15m":4563928.66,"last":4563928.66,"buy":4563928.66,"sell":4563928.66,"symbol":"RUB"},"SEK":{"15m":599979.15,"last":599979.15,"buy":599979.15,"sell":599979.15,"symbol":"SEK"},"SGD":{"15m":81472.88,"last":81472.88,"buy":81472.88,"sell":81472.88,"symbol":"SGD"},"THB":{"15m":2080535.41,"last":2080535.41,"buy":2080535.41,"sell":2080535.41,"symbol":"THB"},"TRY":{"15m":2926544.59,"last":2926544.59,"buy":2926544.59,"sell":2926544.59,"symbol":"TRY"},"TWD":{"15m":2001567.55,"last":2001567.55,"buy":2001567.55,"sell":2001567.55,"symbol":"TWD"},"USD":{"15m":63410.18,"last":63410.18,"buy":63410.18,"sell":63410.18,"symbol":"USD"}} \ No newline at end of file diff --git a/tests/fixtures/price/coingecko_simple_price.json b/tests/fixtures/price/coingecko_simple_price.json new file mode 100644 index 00000000..3edee159 --- /dev/null +++ b/tests/fixtures/price/coingecko_simple_price.json @@ -0,0 +1 @@ +{"bitcoin":{"usd":63410,"eur":54815,"ars":90832019,"jpy":10143476,"gbp":47293,"brl":325345,"cad":88608,"aud":90099,"chf":50481,"cny":429687,"inr":6044351,"mxn":1096060,"clp":57428713,"cop":222196940,"pen":215612,"vnd":1669348380,"krw":96347491,"sek":599979,"nok":601084,"dkk":409666,"pln":233236,"czk":1325546,"huf":19363788,"try":2926546,"zar":1035644,"ngn":86298742,"idr":1137904565,"myr":257908,"php":3881370,"sgd":81473,"thb":2080536,"twd":2001568,"hkd":496969,"nzd":108825,"ils":187917,"aed":232915,"sar":238085,"uah":2847949,"rub":4563930,"pkr":17636481,"bdt":7795676,"lkr":21120528,"mmk":133149366,"kwd":19562.43,"bhd":23915}} \ No newline at end of file diff --git a/tests/fixtures/price/currency_api_btc.json b/tests/fixtures/price/currency_api_btc.json new file mode 100644 index 00000000..c6181d85 --- /dev/null +++ b/tests/fixtures/price/currency_api_btc.json @@ -0,0 +1 @@ +{"date":"2026-06-11","btc":{"1inch":873358.64177631,"aave":990.48704348,"ada":377334.41311854,"aed":229602.12070884,"afn":3911290.60712325,"agix":753590.65429234,"akt":105343.38852263,"algo":704236.31952613,"all":5144157.04781142,"amd":23033933.41025167,"amp":121143618.34485012,"ang":112653.47298278,"aoa":57312397.31945596,"ape":512866.9438841,"apt":98205.15611174,"ar":33864.78874137,"arb":780120.25669436,"ars":89606381.47398803,"atom":33812.69445086,"ats":744999.11494588,"aud":89246.73945399,"avax":9517.98602555,"awg":111909.54283698,"axs":67837.55987532,"azm":531414018.33468133,"azn":106282.80366431,"bake":84576231.63619502,"bam":105890.97759534,"bat":672614.09838708,"bbd":125038.59534858,"bch":312.19129046,"bdt":7675738.40493356,"bef":2184050.47833405,"bgn":105890.97759534,"bhd":23507.25592553,"bif":186834436.44468045,"bmd":62519.29767429,"bnb":105.28179776,"bnd":80463.99389977,"bob":432212.34736016,"brl":324564.99389119,"bsd":62519.29767429,"bsv":5552.22653165,"bsw":195943041.2558728,"btc":1,"btcb":855363.38703476,"btg":312898.37412022,"btn":5979464.19264713,"btt":235195707122.3899,"busd":62527.46298862,"bwp":848765.59095166,"byn":172140.20376712,"byr":1225221994.4030302,"bzd":125889.11900258,"cad":87141.13544125,"cake":47517.28413312,"cdf":144381562.0083922,"celo":1032545.53242265,"cfx":1410030.27504059,"chf":49927.58065193,"chz":2313576.33277798,"clp":57256886.08306731,"cnh":423782.40572379,"cny":423560.45771565,"comp":3471.40119233,"cop":222383340.28531274,"crc":28676284.41049925,"cro":1043704.28029752,"crv":259104.31280755,"cspr":30024800.4138461,"cuc":62511.31792775,"cup":1656550.14165014,"cve":5970149.80814951,"cvx":49032.12220155,"cyp":31687.43501208,"czk":1309274.22318612,"dai":62538.42114929,"dash":1800.36806634,"dcr":5188.57568863,"dem":105890.97759534,"dfi":76710230.01826873,"djf":11124706.08597759,"dkk":404685.08428914,"doge":737358.14037136,"dop":3648953.26235268,"dot":66319.42780728,"dydx":526139.5693646,"dzd":8353735.4267307,"eek":847127.82075026,"egld":21663.89201863,"egp":3239771.50771887,"enj":2164373.40872287,"eos":1009917.81526764,"ern":937789.46511438,"esp":9008337.22661481,"etb":10049260.56984133,"etc":8816.24373224,"eth":37.89596074,"eur":54141.19713333,"eurc":53351.71889082,"fei":62463.27691515,"fil":82593.99327532,"fim":321908.94004749,"fjd":139034.15896149,"fkp":46721.61238003,"flow":2134573.77649122,"flr":8942881.26674085,"frax":62930.45600818,"frf":355142.9724939,"ftt":225026.45490236,"gala":23729935.30769002,"gbp":46721.61238003,"gel":165834.9390846,"ggp":46721.61238003,"ghc":7202917903.884712,"ghs":720291.79038568,"gip":46721.61238003,"gmd":4604455.64368084,"gmx":11325.96662517,"gnf":547951029.0995998,"gno":654.77339407,"grd":18448612.92397797,"grt":3276517.15547917,"gt":9786.54806105,"gtq":476584.08983675,"gusd":62657.70421816,"gyd":13077492.70747889,"hbar":791552.8671025,"hkd":489919.35349278,"hnl":1671649.83914938,"hnt":206928.67306697,"hot":202730100.1664373,"hrk":407926.84981674,"ht":1585575.15194167,"htg":8181572.38055271,"huf":19304983.25824161,"icp":27513.19450141,"idr":1123490534.3556936,"iep":42639.65778212,"ils":185815.35406401,"imp":46721.61238003,"imx":446390.54532456,"inj":11953.91554089,"inr":5979464.19264713,"iqd":81933197.64105527,"irr":85964013068.94534,"isk":7764287.66409181,"itl":104831975.77787504,"jep":46721.61238003,"jmd":9874765.66048675,"jod":44326.18205107,"jpy":10035022.64326886,"kas":2033712.829375,"kava":1453995.34060947,"kcs":9474.17059992,"kda":9102559.67190589,"kes":8094311.89510324,"kgs":5467690.58863284,"khr":251704835.07802707,"klay":1753734.51659107,"kmf":26635722.9371428,"knc":514060.68673805,"kpw":56251145.04232385,"krw":95420038.25515994,"ksm":17409.6811154,"kwd":19335.25104454,"kyd":51994.66801334,"kzt":30510010.76539265,"lak":1375985297.4501765,"lbp":5607006058.40907,"ldo":238622.919796,"leo":6586.30878231,"link":8044.65245672,"lkr":20782420.45140602,"lrc":4876280.02286568,"lrd":11408246.30269154,"lsl":1033729.39789577,"ltc":1465.8735035,"ltl":186938.72546849,"luf":2184050.47833405,"luna":1224624.90965731,"lunc":897779356.4535635,"lvl":38050.43334401,"lyd":399261.26749575,"mad":579214.33518133,"mana":951211.88314565,"mbx":2309812.48553102,"mdl":1087973.63612203,"mga":262361235.68266144,"mgf":1311806178.4133008,"mina":1512182.16162979,"mkd":3338001.21054656,"mkr":44.01064499,"mmk":131231111.02398433,"mnt":223777323.0381863,"mop":504616.93410175,"mro":25054330.36033797,"mru":2505433.03603567,"mtl":23242.81593276,"mur":2992495.9090133,"mvr":966521.01510962,"mwk":108439040.25186457,"mxn":1087296.70990817,"mxv":123463.73423392,"myr":254294.0447404,"mzm":3989977193.779763,"mzn":3989977.1937827,"nad":1033729.39789577,"near":31070.23507929,"neo":28878.16644353,"nexo":78381.37613005,"nft":234521595019.30496,"ngn":85051540.9231668,"nio":2297446.91799648,"nlg":119311.49754222,"nok":591023.51648536,"npr":9571627.30638379,"nzd":107861.63227862,"okb":879.05863672,"omr":24057.12055086,"one":41877252.45205434,"op":672548.91864423,"ordi":19706.53192386,"pab":62519.29767429,"paxg":15.32789416,"pen":212690.53370183,"pepe":22632405244.876316,"pgk":275547.6056298,"php":3828106.44130736,"pi":498103.44398502,"pkr":17402243.76048506,"pln":230137.14984243,"pol":855281.22229699,"pte":10854335.48415177,"pyg":383210352.67218447,"qar":227570.24353442,"qnt":978.79405823,"qtum":89673.45365905,"rol":2834402454.948441,"ron":283440.24549359,"rpl":47663.47317754,"rsd":6354215.51386945,"rub":4516563.91534967,"rune":158755.74253618,"rvn":15038675.35983772,"rwf":91712084.63317722,"sand":1221062.6165324,"sar":234447.3662786,"sbd":502207.12133341,"scr":879804.79822692,"sdd":3752581803.2481327,"sdg":37525818.03248002,"sek":593729.148551,"sgd":80463.99389977,"shib":13299003855.704914,"shp":46721.61238003,"sit":12974396.48158877,"skk":1631057.7049087,"sle":1426998.3562793,"sll":1426998356.2777028,"snx":256243.31339183,"sol":962.43596647,"sos":35730668.3360342,"spl":10419.88290404,"srd":2336618.9759683,"srg":2336618975.9705224,"ssp":295270645.4711315,"std":1339006143.2297022,"stn":1339006.14323182,"stx":342463.56815727,"sui":83314.32373188,"svc":547043.85465006,"syp":6909508.02418241,"szl":1033729.39789577,"thb":2058796.21814174,"theta":398583.42949801,"tjs":583084.05355006,"tmm":1093949735.7849903,"tmt":218789.94715493,"tnd":182890.15609426,"ton":37675.50167217,"top":149091.61544627,"trl":2885389531946.79,"trx":194461.68246369,"try":2885389.53194491,"ttd":424533.66142449,"tusd":62599.0249936,"tvd":89246.73945399,"twd":1981940.54920831,"twt":161739.16014782,"tzs":163794549.7612469,"uah":2815695.2721056,"ugx":235343890.5800151,"uni":25216.87700229,"usd":62519.29767429,"usdc":62510.693627,"usdd":62589.02866001,"usdp":62594.04640871,"usdt":62584.63145902,"uyu":2526387.30073583,"uzs":754294186.7357095,"val":104831975.77787504,"veb":3533584624258374,"ved":35333173.45048812,"vef":3533317345048.6,"ves":35333173.45048812,"vet":13011459.38870935,"vnd":1645601083.4610512,"vuv":7477922.76216438,"waves":245609.79777127,"wemix":242150.34255118,"woo":4833022.52343392,"wst":170231.56268559,"xaf":35514297.24952164,"xag":980.72086664,"xau":15.33615877,"xaut":15.35045814,"xbt":0.99996491,"xcd":169259.27377162,"xcg":112653.47298278,"xch":31311.07249041,"xdc":2023318.00985592,"xdr":45816.48735361,"xec":11753178500.95859,"xem":115360933.60514471,"xlm":327721.28878698,"xmr":183.90915621,"xof":35514297.24952164,"xpd":50.1990073,"xpf":6460763.38131908,"xpt":37.50915286,"xrp":56094.07510802,"xtz":268988.30905786,"yer":14919549.60724687,"zar":1033729.39789577,"zec":149.18755169,"zil":20385995.02912681,"zmk":1097221982.8932648,"zmw":1097221.98289221,"zwd":22625733.82832639,"zwg":1674340.24944781,"zwl":4183714500.3348413}} \ No newline at end of file From ed106785ba5fe42240e4e5aceb348253da6c7224 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 16 Jun 2026 12:53:49 -0300 Subject: [PATCH 12/23] Update CHANGELOG for version 0.17.5 --- CHANGELOG.md | 60 ++++++++++++++++++---------------------------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8809d03..b7d2928c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,58 +27,40 @@ gpg: Good signature from "Catrya (github) <140891948+Catrya@users.noreply.github That will verify the signature of the manifest file, which ensures integrity and authenticity of the archive you've downloaded locally containing the binaries. Next, depending on your operating system, you should then re-compute the sha256 hash of the archive with `shasum -a 256 `, compare it with the corresponding one in the manifest file, and ensure they match exactly. -## What's Changed in 0.17.4 +## What's Changed in 0.17.5 ### 🚀 Features -* feat(bond): Phase 3.5 — payout confirmation to the winner by [@grunch](https://github.com/grunch) in [#743](https://github.com/MostroP2P/mostro/pull/743) -* feat(bond): Phase 3 — payout flow for slashed bonds by [@grunch](https://github.com/grunch) in [#738](https://github.com/MostroP2P/mostro/pull/738) -* feat(bond): Phase 2 — solver-directed dispute slash by [@grunch](https://github.com/grunch) in [#737](https://github.com/MostroP2P/mostro/pull/737) -* feat: support MOSTRO_NSEC_PRIVKEY env var for Nostr private key by [@AndreaDiazCorreia](https://github.com/AndreaDiazCorreia) in [#713](https://github.com/MostroP2P/mostro/pull/713) -* feat(bond): Phase 1.5 — dedicated PayBondInvoice action + WaitingTakerBond status by [@grunch](https://github.com/grunch) in [#736](https://github.com/MostroP2P/mostro/pull/736) -* feat(bond): concurrent taker bonds, first-to-lock wins (Phase 0+1) by [@grunch](https://github.com/grunch) in [#733](https://github.com/MostroP2P/mostro/pull/733) -* feat(bond): align AntiAbuseBondSettings with spec — slash split, claim window, drop unused dispute flag by [@grunch](https://github.com/grunch) in [#728](https://github.com/MostroP2P/mostro/pull/728) -* feat(bond): add Forfeited terminal state for long-stop bond payout by [@grunch](https://github.com/grunch) in [#727](https://github.com/MostroP2P/mostro/pull/727) -* feat(bond): add Phase 0 schema columns for split, forfeit window, and retry separation by [@grunch](https://github.com/grunch) in [#726](https://github.com/MostroP2P/mostro/pull/726) -* feat: added catrya key for manifest signature by [@Catrya](https://github.com/Catrya) in [#724](https://github.com/MostroP2P/mostro/pull/724) -* feat(bond): anti-abuse bond phase 1 — taker lifecycle (lock + always release) by [@grunch](https://github.com/grunch) in [#719](https://github.com/MostroP2P/mostro/pull/719) -* feat(nip59): adopt mostro-core 0.10.0 dual-key gift wrap transport by [@grunch](https://github.com/grunch) in [#718](https://github.com/MostroP2P/mostro/pull/718) -* feat(bond): anti-abuse bond phase 0 foundation by [@grunch](https://github.com/grunch) in [#712](https://github.com/MostroP2P/mostro/pull/712) +* feat(price): Phase 2 — direct backup quoters + multi-source aggregation by [@grunch](https://github.com/grunch) in [#773](https://github.com/MostroP2P/mostro/pull/773) +* feat(bond): Phase 7 — maker timeout slash by [@grunch](https://github.com/grunch) in [#775](https://github.com/MostroP2P/mostro/pull/775) +* feat(bond): Phase 6 — range-order maker bond with proportional slashes by [@grunch](https://github.com/grunch) in [#770](https://github.com/MostroP2P/mostro/pull/770) +* feat(price): Phase 1 — Yadio provider + PriceManager wiring by [@grunch](https://github.com/grunch) in [#753](https://github.com/MostroP2P/mostro/pull/753) +* feat(bond): Phase 5 — maker bond (non-range) + dispute slash by [@grunch](https://github.com/grunch) in [#767](https://github.com/MostroP2P/mostro/pull/767) +* feat(bond): Phase 4.5 — re-prompt winner for payout invoice on payment failure by [@grunch](https://github.com/grunch) in [#755](https://github.com/MostroP2P/mostro/pull/755) +* feat(bond): Phase 4 — timeout slash for the taker bond by [@grunch](https://github.com/grunch) in [#744](https://github.com/MostroP2P/mostro/pull/744) +* feat(price): Phase 0 — multi-source price module foundation by [@grunch](https://github.com/grunch) in [#747](https://github.com/MostroP2P/mostro/pull/747) ### 🐛 Bug Fixes -* fix(price): tolerate null rates in Yadio /exrates/BTC response by [@grunch](https://github.com/grunch) in [#748](https://github.com/MostroP2P/mostro/pull/748) -* fix: include created_at on AddInvoice SmallOrder by [@arkanoider](https://github.com/arkanoider) in [#739](https://github.com/MostroP2P/mostro/pull/739) -* fix(bond): align bond invoice memo with spec §6.1 by [@grunch](https://github.com/grunch) in [#735](https://github.com/MostroP2P/mostro/pull/735) -* fix(restore-session): re-send AddInvoice for failed payments on session restore by [@codaMW](https://github.com/codaMW) in [#721](https://github.com/MostroP2P/mostro/pull/721) - -### 💼 Other - - -* Revert "fix(restore-session): re-send AddInvoice for failed payments on session restore" by [@grunch](https://github.com/grunch) in [#722](https://github.com/MostroP2P/mostro/pull/722) -* Add read and read-write dispute solver permissions by [@mostronatorcoder[bot]](https://github.com/mostronatorcoder[bot]) in [#708](https://github.com/MostroP2P/mostro/pull/708) +* fix(price): repair test-only price seeding broken by #753/#770 merge skew by [@grunch](https://github.com/grunch) in [#774](https://github.com/MostroP2P/mostro/pull/774) +* fix: let daemon finalize disputes without solver row by [@arkanoider](https://github.com/arkanoider) in [#746](https://github.com/MostroP2P/mostro/pull/746) ### 📚 Documentation -* docs: spec for multi-source price providers (remove Yadio single point of failure) by [@grunch](https://github.com/grunch) in [#745](https://github.com/MostroP2P/mostro/pull/745) -* docs(bond): add Phase 3.5 — payout confirmation to the winner by [@grunch](https://github.com/grunch) in [#742](https://github.com/MostroP2P/mostro/pull/742) -* docs(bond): fold taker_* columns into Phase 0 schema by [@grunch](https://github.com/grunch) in [#734](https://github.com/MostroP2P/mostro/pull/734) -* docs(bond): switch Phase 1.5 to concurrent taker bonds, first-to-lock wins by [@grunch](https://github.com/grunch) in [#732](https://github.com/MostroP2P/mostro/pull/732) -* docs(bond): spec cancel_action handling for WaitingTakerBond status by [@grunch](https://github.com/grunch) in [#730](https://github.com/MostroP2P/mostro/pull/730) -* docs(bond): note that mostro-core 0.11.0 ships Phase 1.5 + Phase 2 variants by [@grunch](https://github.com/grunch) in [#729](https://github.com/MostroP2P/mostro/pull/729) -* docs(bond): decouple slash from trade outcome; clarify maker/taker vs… by [@grunch](https://github.com/grunch) in [#725](https://github.com/MostroP2P/mostro/pull/725) +* docs(bond): Phase 8 — public config exposure + operator docs by [@grunch](https://github.com/grunch) in [#777](https://github.com/MostroP2P/mostro/pull/777) +* docs: document daemon event kinds by [@ermeme[bot]](https://github.com/ermeme[bot]) in [#769](https://github.com/MostroP2P/mostro/pull/769) +* docs: clarify Cashu escrow uses per-order trade keys by [@grunch](https://github.com/grunch) in [#757](https://github.com/MostroP2P/mostro/pull/757) +* docs: add Cashu 2-of-3 multisig escrow architecture spec by [@a1denvalu3](https://github.com/a1denvalu3) in [#756](https://github.com/MostroP2P/mostro/pull/756) ## Contributors -* [@grunch](https://github.com/grunch) made their contribution in [#748](https://github.com/MostroP2P/mostro/pull/748) -* [@arkanoider](https://github.com/arkanoider) made their contribution in [#739](https://github.com/MostroP2P/mostro/pull/739) -* [@AndreaDiazCorreia](https://github.com/AndreaDiazCorreia) made their contribution in [#713](https://github.com/MostroP2P/mostro/pull/713) -* [@Catrya](https://github.com/Catrya) made their contribution in [#724](https://github.com/MostroP2P/mostro/pull/724) -* [@codaMW](https://github.com/codaMW) made their contribution in [#721](https://github.com/MostroP2P/mostro/pull/721) -* [@mostronatorcoder[bot]](https://github.com/mostronatorcoder[bot]) made their contribution in [#708](https://github.com/MostroP2P/mostro/pull/708) - -**Full Changelog**: https://github.com/MostroP2P/mostro/compare/v0.17.3...0.17.4 +* [@grunch](https://github.com/grunch) made their contribution in [#773](https://github.com/MostroP2P/mostro/pull/773) +* [@ermeme[bot]](https://github.com/ermeme[bot]) made their contribution in [#769](https://github.com/MostroP2P/mostro/pull/769) +* [@a1denvalu3](https://github.com/a1denvalu3) made their contribution in [#756](https://github.com/MostroP2P/mostro/pull/756) +* [@arkanoider](https://github.com/arkanoider) made their contribution in [#746](https://github.com/MostroP2P/mostro/pull/746) + +**Full Changelog**: https://github.com/MostroP2P/mostro/compare/v0.17.4...0.17.5 From 5b07be2a107b3bc5a66d9ffb7ec4bb5bb8695b47 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 16 Jun 2026 12:53:51 -0300 Subject: [PATCH 13/23] chore: Release mostro version 0.17.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 376c1b84..320e8949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1730,7 +1730,7 @@ dependencies = [ [[package]] name = "mostro" -version = "0.17.4" +version = "0.17.5" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 1aebca48..897221a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mostro" -version = "0.17.4" +version = "0.17.5" edition = "2021" license = "MIT" authors = ["Francisco Calderón "] From ba86102d515e91e135c1b0428af4faa09ea3beb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Tue, 16 Jun 2026 19:17:47 +0200 Subject: [PATCH 14/23] =?UTF-8?q?feat(transport):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20wire=20protocol=20v2=20(NIP-44=20direct)=20into=20mostrod=20?= =?UTF-8?q?(#776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transport): Phase 1 — wire protocol v2 (NIP-44 direct) into mostrod Phase 1 of the Messaging Transport Abstraction Layer (#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 * 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 --------- Co-authored-by: Claude Fable 5 --- Cargo.lock | 4 +- Cargo.toml | 2 +- docs/TRANSPORT_V2_SPEC.md | 241 ++++++++++++++++++++++++++++++++++++++ settings.tpl.toml | 10 ++ src/app.rs | 25 ++-- src/config/constants.rs | 5 + src/config/settings.rs | 13 ++ src/config/types.rs | 29 ++++- src/main.rs | 4 +- src/nip33.rs | 8 ++ src/util.rs | 23 +++- 11 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 docs/TRANSPORT_V2_SPEC.md diff --git a/Cargo.lock b/Cargo.lock index 320e8949..f3b2e4de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1770,9 +1770,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92c273ca52a38a27cdd74f3635055e092c0253d7fade399d64a1d5be63f8563" +checksum = "d763fddd85d86033f78dacadb8b75041100af7dae4d50e5bea9dc91fc1a1d24c" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 897221a7..91531af7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = { version = "0.12.1", features = ["sqlx"] } +mostro-core = { version = "0.13.0", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } async-trait = "0.1.83" diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md new file mode 100644 index 00000000..752132d8 --- /dev/null +++ b/docs/TRANSPORT_V2_SPEC.md @@ -0,0 +1,241 @@ +# Transport v2 — NIP-44 Direct Messaging (Protocol v2) + +**Status:** Phase 1 implemented (this spec ships with it) · Phases 2–4 pending +**Issue:** [#626 — Messaging Transport Abstraction Layer](https://github.com/MostroP2P/mostro/issues/626) +**Full proposal:** [issue comment](https://github.com/MostroP2P/mostro/issues/626#issuecomment-4694164653) +**Core implementation:** [mostro-core#152](https://github.com/MostroP2P/mostro-core/pull/152), released in mostro-core **0.13.0** (`transport` module) + +## 1. Context and motivation + +Mostro historically used NIP-59 Gift Wrap (kind `1059`) as its only wire +transport. Gift wraps give strong metadata privacy, but they are *opaque*: +the outer event is signed by a random throwaway key, so neither relays nor +the daemon can tell legitimate traffic from garbage without paying the full +decrypt cost. That makes Mostro vulnerable to a "Gift Wrap Apocalypse" — +spam floods that relays cannot rate-limit by sender and that force the +daemon to attempt NIP-44 decryption on every event (see the threat model in +issue #626). + +The accepted direction (issue discussion): trade abuse-resistance for a +bounded amount of metadata. Mostro already rotates trade keys per trade — +the publicly exposed key for a given trade is short-lived, single-purpose +and never reused — so a *visible, rate-limitable* envelope leaks little, +while enabling: + +- relay-side rate limiting by sender pubkey, and +- daemon-side cheap pre-validation **before** decrypting (Phase 2). + +Protocol **v2** is that envelope: a signed kind-`14` event whose content is +NIP-44 encrypted. Protocol **v1** (gift wrap) is frozen and DEPRECATED. + +## 2. Wire format (protocol v2) + +### 2.1 Visible envelope + +What relays and observers see: + +```json +{ + "kind": 14, + "pubkey": "", + "content": "", + "tags": [ + ["p", ""], + ["expiration", ""] + ], + "created_at": 1234567890, + "sig": "" +} +``` + +- **Author = trade key.** The event signature proves trade-key authorship + (unlike v1, where the outer event is signed by a throwaway ephemeral key). + This is what makes the transport rate-limitable and pre-filterable. +- **`expiration` (NIP-40):** trade messages are only relevant for the + lifetime of a trade plus a dispute window, so they always carry an + expiration tag (default 30 days, `dm_days` setting) instead of sitting on + relays forever. +- **Mostro → user direction:** Mostro authors the event with its own + well-known key, `p`-tagged to the user's trade key. Clients can subscribe + with `authors=[mostro] AND #p=[trade keys]`. +- **NIP-17 deviation (deliberate):** NIP-17 defines kind 14 as an *unsigned* + rumor that only travels inside a gift wrap. Mostro publishes it *signed*, + because the author is an ephemeral single-trade key — the association the + NIP-17 rule protects against is intentional and bounded. These events are + not standard NIP-17 chats. + +### 2.2 Encrypted content + +The NIP-44 conversation key is derived from (trade key ↔ counterparty), so +only the two parties can decrypt. The plaintext is a JSON 3-element tuple — +v1's 2-tuple plus an identity proof: + +```json +[ + { "order": { "version": 2, "...": "..." } }, + "", + ["", ""] // or null +] +``` + +| element | meaning | +|---|---| +| 1 | the logical `Message` (unchanged from v1, but `version: 2`) | +| 2 | trade key's `Message::sign` over the serialized first element, or `null` (Mostro's own messages are unsigned, as in v1) | +| 3 | identity proof `[identity_pubkey, identity_sig]`, or `null` for **full-privacy mode** (identity = trade key, mirroring v1's unsigned-rumor convention) | + +### 2.3 Identity proof + +In v1 the long-lived identity key is carried *authenticated* by the seal +(`identity = seal.pubkey`, hidden inside the wrap). v2 has no seal, so the +identity travels **inside the ciphertext** — never visible at the event +level, exactly as private as before — proven by a signature over the +domain-tagged payload: + +```text +mostro-transport-v2-identity:: +``` + +Including the trade pubkey binds the proof to the *specific trade key* +authoring the event (the binding v1 gets from the seal signature covering +the encrypted rumor). Signing the message JSON alone would let any party +that sees a plaintext tuple — the receiving node, or a compromised one — +graft the `(identity_pubkey, identity_sig)` pair onto an event authored by +a different trade key and have the identity misattributed. The receiver +recomputes the payload from `event.pubkey`, so a grafted proof fails +verification. (Found by review on mostro-core#152; regression-tested there.) + +The signature scheme is the existing `Message::sign` / +`Message::verify_signature` (Schnorr over sha256). The identity key signs +once per message — the same custody model as v1, where it signs every seal. + +## 3. Versioning + +- `Message.version` is **2** (mostro-core `PROTOCOL_VER`, since 0.13.0). +- **v1** = gift wrap + 2-tuple, frozen. **v2** = kind-14 direct + 3-tuple. +- Which parser applies is keyed off the **event kind** (`1059` vs `14`), + not the version field. mostro-core's `unwrap_incoming()` dispatches and + returns the same `UnwrappedMessage` for both, which is why daemon + handlers needed no changes. + +## 4. Operator configuration — one transport per node + +There is **no dual mode**: a node speaks exactly one protocol version. + +```toml +[mostro] +# "gift-wrap" (protocol v1, DEPRECATED) | "nip44" (protocol v2) +transport = "gift-wrap" + +[expiration] +# kind-14 direct messages +dm_days = 30 +``` + +| `transport` | event kind | who can trade on this node | +|---|---|---| +| `gift-wrap` *(default in 0.18.x)* | 1059 (v1) | every current client — wire behavior identical to pre-v2 daemons | +| `nip44` | 14 (v2) | v2-capable clients only — the only mode from v0.19.0 | + +**Capability discovery:** the node advertises its protocol in the kind +`38385` instance-info event with a `protocol_versions` tag (`"1"` or +`"2"`, derived from `transport`). Old clients ignore the unknown tag; +v2-capable clients check it and use the matching wire format — a client +implementation should keep both wrap paths (mostro-core ships both) to +talk to v1 and v2 nodes during the transition. + +Switching a community to v2 is a deliberate operator decision, coordinated +with the clients that community uses. + +## 5. Release timeline + +- **v0.18.0** — protocol v2 ships. Default `transport = "gift-wrap"` + (nothing changes for existing clients). **Protocol v1 is DEPRECATED**: + announced in release notes, protocol docs and the `protocol_versions` + tag. Client developers have the 0.18.x cycle to ship v2. +- **v0.19.0** — protocol v2 becomes the default and only protocol. + Everything v1-related is removed from mostrod (gift-wrap path, + `"gift-wrap"` setting value, v1 acceptance). mostro-core keeps its + gift-wrap helpers for clients' own migration needs. + +## 6. Implementation phases + +### Phase 0 — mostro-core (DONE — mostro-core#152, released 0.13.0) + +The bulk of the work, all additive, in mostro-core's `transport` module: + +- `wrap_message_nip44` / `unwrap_message_nip44` — the v2 wrap/unwrap pair + (`Ok(None)` keeps its "not addressed to me" meaning). +- `unwrap_incoming` — kind dispatch returning the same `UnwrappedMessage` + for both transports. +- `wrap_message_with` — send-side dispatcher. +- `Transport` enum — serde/`FromStr` for the config values, `event_kind()`, + `protocol_version()`. Default `GiftWrap`. +- `PROTOCOL_VER` 1 → 2; v1 fixtures kept as parse-regression tests. +- Identity proof bound to the trade key via the domain-tagged payload + (§2.3), with a grafting regression test. + +### Phase 1 — mostrod wiring (DONE — this change) + +Minimal daemon integration; **zero handler changes** by design: + +- `mostro-core` 0.12.1 → **0.13.0**. +- `[mostro] transport` setting (`Transport`, serde default = `gift-wrap`) + in `src/config/types.rs` + `settings.tpl.toml`. +- `[expiration] dm_days` knob (default 30) in `ExpirationSettings` and the + `get_expiration_timestamp_for_kind` fallback (`DM_EVENT_KIND = 14` in + `src/config/constants.rs`). +- `src/main.rs` — subscription filter uses `transport.event_kind()`. +- `src/app.rs` — event loop accepts only the configured kind and unwraps + via `unwrap_incoming()`. +- `src/util.rs send_dm()` — wraps via `wrap_message_with(transport, …)`; + on the nip44 transport, fills a default NIP-40 expiration from `dm_days` + when the caller didn't pass one. +- `src/nip33.rs` — `protocol_versions` tag in the kind-38385 info event. + +### Phase 2 — anti-spam gates (PENDING — daemon-only, the payoff) + +The reason v2 exists: reject junk *before* paying decrypt/parse costs. + +- Cache of active trade pubkeys (open orders/disputes), refreshed on state + changes. +- Cheap pre-validation in the event loop for kind 14: check `event.pubkey` + against the cache **before** decrypting. +- Two lanes — this is the necessary nuance to "only accept known keys": + brand-new orders and takes arrive from keys Mostro has never seen, so + there is a *known-keys lane* (pre-validated, cheap) and a *first-contact + lane* (where spam lives; PoW + relay rate-limiting apply there). +- TTL / stale-event rejection and dedup as defense in depth. + +### Phase 3 — protocol docs + client migration (PENDING) + +- Update the protocol repo (`MostroP2P/protocol`): `overview.md` ("The + Message": both transports, the v2 tuple, `version: 2`), + `key_management.md` (v2 examples mirroring the existing unencrypted + gift-wrap walkthroughs), migration guide for client developers. +- mostro-cli / client support via the same mostro-core 0.13.0 APIs: + clients keep both wrap paths and pick per node from `protocol_versions`. + +### Phase 4 — the v0.19.0 cutover (PENDING) + +- Default `transport = "nip44"`; remove the v1 path from mostrod entirely + (per §5). Metrics: `messages_received_total`, decrypt failures as a spam + indicator. + +## 7. Security notes + +- **Identity privacy is unchanged from v1:** the identity pubkey only ever + exists inside NIP-44 ciphertext readable by the two parties. What v2 + newly exposes is *activity* of an ephemeral trade key (who talks to + Mostro, when, how much) — accepted, bounded by per-trade key rotation. +- **Identity proof grafting** is prevented by the trade-pubkey binding + (§2.3). The trade signature (element 2) needs no domain tag because it is + verified against `event.pubkey` — a foreign trade_sig under a different + author fails by construction. +- **Event signature is load-bearing in v2** (it proves the visible sender): + `unwrap_message_nip44` verifies it and hard-errors, unlike v1 where the + outer signature is from a throwaway key and the seal carries the trust. +- The daemon's existing checks (PoW, 10-second freshness window, trade + index, `identity != sender && signature.is_none()` bail-out) apply + unchanged to both transports because both yield the same + `UnwrappedMessage`. diff --git a/settings.tpl.toml b/settings.tpl.toml index ccef384d..0045fc29 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -52,6 +52,14 @@ user_rates_sent_interval_seconds = 3600 publish_relays_interval = 60 # Requested POW pow = 0 +# Wire transport for protocol messages. A node speaks exactly one: +# "gift-wrap" - protocol v1, NIP-59 gift wraps (kind 1059). DEPRECATED, +# will be removed in v0.19.0. +# "nip44" - protocol v2, signed kind-14 events with NIP-44 encrypted +# content. Rate-limitable by relays; switch once the clients +# your community uses support protocol v2. +# See docs/TRANSPORT_V2_SPEC.md +transport = "gift-wrap" # Publish mostro info interval publish_mostro_info_interval = 300 # Bitcoin price API base URL. @@ -87,6 +95,8 @@ rating_days = 90 dispute_days = 90 # Fee audit events (kind 8383) - annual transparency fee_audit_days = 365 +# Protocol-v2 direct messages (kind 14) - trade lifetime plus dispute window +dm_days = 30 [rpc] # Enable RPC server for direct admin communication diff --git a/src/app.rs b/src/app.rs index 0a22ca83..fd8afbfc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,8 @@ use mostro_core::error::CantDoReason; use mostro_core::error::MostroError; use mostro_core::error::ServiceError; use mostro_core::message::{Action, Message}; -use mostro_core::nip59::{unwrap_message, UnwrappedMessage}; +use mostro_core::nip59::UnwrappedMessage; +use mostro_core::transport::unwrap_incoming; use mostro_core::user::User; use nostr_sdk::prelude::*; @@ -300,6 +301,10 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { let my_keys = ctx.keys(); let client = ctx.nostr_client(); let pow = ctx.settings().mostro.pow; + // The node speaks exactly one transport (protocol v1 gift wrap or v2 + // NIP-44 direct); events of any other kind are dropped before any + // decryption work. See docs/TRANSPORT_V2_SPEC.md. + let accepted_kind = ctx.settings().mostro.transport.event_kind(); loop { let mut notifications = client.notifications(); @@ -312,22 +317,24 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { tracing::info!("Not POW verified event!"); continue; } - if let Kind::GiftWrap = event.kind { + if event.kind == accepted_kind { // Validate event signature if event.verify().is_err() { tracing::warn!("Error in event verification") }; - // Mostro-core's NIP-59 transport handles the dual-key layout - // (identity key signs seal, trade key authors rumor) plus inner - // tuple (message, signature) decoding and signature verification - // in one shot. - let unwrapped = match unwrap_message(&event, my_keys).await { + // Mostro-core dispatches on the event kind: the gift wrap + // path handles the dual-key layout (identity key signs + // seal, trade key authors rumor), the kind-14 path the + // 3-element tuple with its in-ciphertext identity proof. + // Both decode and verify signatures in one shot and yield + // the same transport-agnostic `UnwrappedMessage`. + let unwrapped = match unwrap_incoming(&event, my_keys).await { Ok(Some(u)) => u, - // Outer NIP-44 decrypt failed: not addressed to this node. + // NIP-44 decrypt failed: not addressed to this node. Ok(None) => continue, Err(e) => { - tracing::warn!("Error unwrapping NIP-59 message: {}", e); + tracing::warn!("Error unwrapping incoming message: {}", e); continue; } }; diff --git a/src/config/constants.rs b/src/config/constants.rs index a33ec105..ce5d3b5f 100644 --- a/src/config/constants.rs +++ b/src/config/constants.rs @@ -13,6 +13,11 @@ pub const DEV_FEE_LIGHTNING_ADDRESS: &str = "pivotaldeborah52@walletofsatoshi.co /// This ensures events are NOT replaceable, maintaining complete audit history pub const DEV_FEE_AUDIT_EVENT_KIND: u16 = 8383; +/// Nostr event kind for protocol-v2 direct messages (NIP-44 direct transport) +/// Kind 14 carries Mostro protocol messages as signed events with NIP-44 +/// encrypted content when `transport = "nip44"` (see docs/TRANSPORT_V2_SPEC.md) +pub const DM_EVENT_KIND: u16 = 14; + /// Nostr event kind for exchange rates (NIP-33 addressable event) /// Kind 30078 is in the replaceable events range (30000-39999) per NIP-33 /// This allows the same Mostro instance to publish updated rates that replace previous events diff --git a/src/config/settings.rs b/src/config/settings.rs index 7938f0c2..299f0311 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,6 +4,7 @@ use crate::config::types::{ NostrSettings, RpcSettings, }; use crate::price::PriceSettings; +use mostro_core::transport::Transport; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -99,6 +100,18 @@ impl Settings { MOSTRO_CONFIG.get()?.anti_abuse_bond.as_ref() } + /// Wire transport for protocol messages. Falls back to the default + /// (`gift-wrap`, protocol v1) when the global settings haven't been + /// initialized yet — `send_dm()` sits on every reply path and must + /// degrade to v1 behavior rather than panic in unit tests that don't + /// bring up the full configuration, mirroring [`Settings::get_bond`]. + pub fn get_transport() -> Transport { + MOSTRO_CONFIG + .get() + .map(|s| s.mostro.transport) + .unwrap_or_default() + } + /// Retrieve the multi-source price configuration from the global /// `MOSTRO_CONFIG`. Returns `None` when the `[price]` block is absent /// (Phase 1 synthesises a legacy default in that case) and also when the diff --git a/src/config/types.rs b/src/config/types.rs index 85615283..f3ea77c3 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,6 +1,6 @@ // File with the types for the configuration settings // Initialize the types for the configuration settings -use crate::config::constants::DEV_FEE_AUDIT_EVENT_KIND; +use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DM_EVENT_KIND}; use crate::config::MOSTRO_CONFIG; use mostro_core::prelude::*; use serde::{Deserialize, Serialize}; @@ -169,6 +169,8 @@ pub struct ExpirationSettings { pub dispute_days: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fee_audit_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dm_days: Option, } impl ExpirationSettings { @@ -179,6 +181,7 @@ impl ExpirationSettings { NOSTR_RATING_EVENT_KIND => self.rating_days.or(Some(90)), // ratings NOSTR_DISPUTE_EVENT_KIND => self.dispute_days.or(Some(90)), // disputes DEV_FEE_AUDIT_EVENT_KIND => self.fee_audit_days.or(Some(365)), // fee audits + DM_EVENT_KIND => self.dm_days.or(Some(30)), // protocol-v2 direct messages _ => None, // unknown kinds don't get expiration } } @@ -218,6 +221,24 @@ mod tests { ); } + #[test] + fn dm_kind_respects_configured_days_and_falls_back_to_30() { + let settings = ExpirationSettings { + dm_days: Some(7), + ..Default::default() + }; + assert_eq!(settings.get_expiration_for_kind(DM_EVENT_KIND), Some(7)); + let settings = ExpirationSettings::default(); + assert_eq!(settings.get_expiration_for_kind(DM_EVENT_KIND), Some(30)); + } + + #[test] + fn transport_defaults_to_gift_wrap() { + // v0.18.x default: wire-identical to pre-v2 daemons. The default + // flips to nip44 in v0.19.0 (docs/TRANSPORT_V2_SPEC.md §5). + assert_eq!(MostroSettings::default().transport, Transport::GiftWrap); + } + #[test] fn dispute_kind_falls_back_to_90_when_unconfigured() { let settings = ExpirationSettings::default(); @@ -371,6 +392,11 @@ pub struct MostroSettings { /// Exchange rates update interval in seconds (default: 300 = 5 minutes) #[serde(default = "default_exchange_rates_update_interval")] pub exchange_rates_update_interval_seconds: u64, + /// Wire transport for protocol messages: `"gift-wrap"` (protocol v1, + /// NIP-59, DEPRECATED) or `"nip44"` (protocol v2, kind-14 direct). + /// A node speaks exactly one. See docs/TRANSPORT_V2_SPEC.md. + #[serde(default)] + pub transport: Transport, } fn default_bitcoin_price_api_url() -> String { @@ -416,6 +442,7 @@ impl Default for MostroSettings { website: None, publish_exchange_rates_to_nostr: default_publish_exchange_rates(), exchange_rates_update_interval_seconds: default_exchange_rates_update_interval(), + transport: Transport::default(), } } } diff --git a/src/main.rs b/src/main.rs index c823538f..4b875383 100644 --- a/src/main.rs +++ b/src/main.rs @@ -74,9 +74,11 @@ async fn main() -> Result<()> { // Get mostro keys let mostro_keys = util::get_keys()?; + // Subscribe only to the configured transport's kind: 1059 (protocol v1 + // gift wrap) or 14 (protocol v2 NIP-44 direct). See docs/TRANSPORT_V2_SPEC.md. let subscription = Filter::new() .pubkey(mostro_keys.public_key()) - .kind(Kind::GiftWrap) + .kind(Settings::get_mostro().transport.event_kind()) .limit(0); let client = match get_nostr_client() { diff --git a/src/nip33.rs b/src/nip33.rs index f26bfaa8..a73064ac 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -531,6 +531,14 @@ pub fn info_to_tags(ln_status: &LnStatus) -> Tags { TagKind::Custom(Cow::Borrowed("pow")), vec![mostro_settings.pow.to_string()], ), + // Capability advertisement: which Mostro protocol version this node + // speaks ("1" = gift wrap, "2" = NIP-44 direct), derived from the + // `transport` setting so clients pick the right wire format before + // sending anything. See docs/TRANSPORT_V2_SPEC.md. + Tag::custom( + TagKind::Custom(Cow::Borrowed("protocol_versions")), + vec![mostro_settings.transport.protocol_version().to_string()], + ), Tag::custom( TagKind::Custom(Cow::Borrowed("hold_invoice_expiration_window")), vec![ln_settings.hold_invoice_expiration_window.to_string()], diff --git a/src/util.rs b/src/util.rs index 36d1ec89..686e01d1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,6 @@ -use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS}; +use crate::config::constants::{ + DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS, DM_EVENT_KIND, +}; use crate::config::settings::{get_db_pool, Settings}; use crate::config::*; use crate::db; @@ -310,6 +312,9 @@ pub fn get_expiration_timestamp_for_kind(kind: u16) -> Option { let mostro_settings = Settings::get_mostro(); Some(now + Duration::days(mostro_settings.max_expiration_days.into()).num_seconds()) } + // Protocol-v2 direct messages: same 30-day default as + // `ExpirationSettings::get_expiration_for_kind`. + DM_EVENT_KIND => Some(now + Duration::days(30).num_seconds()), _ => None, } } @@ -755,10 +760,24 @@ pub async fn send_dm( let message = Message::from_json(payload) .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; + // Non-panicking accessor: send_dm sits on every reply path and is + // exercised by unit tests that don't initialize the global config. + let transport = Settings::get_transport(); + + // Kind-14 events are visible to relays, so they always carry a NIP-40 + // expiration tag (default 30 days via `dm_days`) instead of lingering + // forever. Callers that pass an explicit expiration keep it. + let expiration = match (transport, expiration) { + (Transport::Nip44Direct, None) => get_expiration_timestamp_for_kind(DM_EVENT_KIND) + .map(|secs| Timestamp::from_secs(secs as u64)), + (_, exp) => exp, + }; + // Mostro node holds a single keypair: it doubles as identity and trade key. // Server-originated messages are unsigned because clients don't track a // trade_index for the node. - let event = wrap_message( + let event = wrap_message_with( + transport, &message, sender_keys, sender_keys, From 68a125ea256ab9f7e2742b5379d77136e6783cc7 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:20:10 -0600 Subject: [PATCH 15/23] feat(bond): notify slashed party on dispute slash (#768) (#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 (#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 --- docs/ANTI_ABUSE_BOND.md | 31 +++- src/app/admin_cancel.rs | 21 ++- src/app/admin_settle.rs | 21 ++- src/app/bond/slash.rs | 324 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 367 insertions(+), 30 deletions(-) diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index 79ee8b90..5ffa4ba2 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -188,6 +188,7 @@ slash path. | 1 | Taker bond lifecycle: **lock + always release** (no slashing yet) | 0 | ✅ shipped (PR #719) | | 1.5 | Protocol cleanup: dedicated `Action::PayBondInvoice` + `Status::WaitingTakerBond` (retire the Phase 1 `PayInvoice` reuse) | 1 | ✅ shipped (PR #736) | | 2 | Solver-directed dispute slash via `BondResolution` payload (taker bond) | 1.5 | ✅ shipped (PR #737) | +| 2.5 | Forfeiture notice on dispute slash: send `Action::BondSlashed` to each slashed party, matching the timeout path ([issue #768](https://github.com/MostroP2P/mostro/issues/768)) | 2, 6 | 🚧 in progress | | 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | ✅ shipped (PR #738) | | 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | ✅ shipped (PR #743) | | 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | ✅ shipped (PR #744) | @@ -217,7 +218,10 @@ protocol variant those phases need (`Status::WaitingTakerBond`, `Action::BondPayoutCompleted`, `Action::BondSlashed`). Phases 6, 7 and 8 are daemon-only (no protocol/schema change) — Phase 8 in particular adds no code beyond the info-event tags already shipped in Phase 3 (§13.1); it -is documentation polish. The feature is now **complete**. +is documentation polish. The feature is **complete**; the one open +follow-up is Phase 2.5 (#768), a daemon-only hardening that sends the +already-existing `Action::BondSlashed` notice on the dispute-slash path too, +so it needs no protocol or schema change. --- @@ -956,6 +960,20 @@ or a legacy admin client): When both bonds are slashed in a single dispute, this loop runs `settle_hold_invoice` **once per bond** — two HTLCs claimed before the slash step returns. +5. **Notify each slashed party** with a best-effort `Action::BondSlashed` + forfeiture notice carrying the slashed amount ([issue #768](https://github.com/MostroP2P/mostro/issues/768)). + This mirrors the timeout-slash path (§9): a settle/cancel resolution + otherwise produces the **same** order message whether or not a bond was + slashed, leaving the loser with no protocol signal that they forfeited a + bond. `apply_bond_resolution` returns the bond rows whose slash is + **confirmed** (via the durable `slashed_reason = LostDispute` witness, or + a freshly-inserted range slice child row); the handler sends one notice + per returned row. A transient settle failure (bond left `Locked`) and an + idempotent admin retry both yield no row, so the notice is never + untruthful and a winner is never re-notified. The amount is the full bond + for a taker / non-range maker bond, or the slice's proportional amount for + a range maker bond. Like the timeout notice it is fire-and-forget — a + dropped message never rolls back the slash. The recipient payout (asking the winning counterparty for a bolt11, `send_payment`, retries, forfeiture on the long-stop window) is @@ -985,6 +1003,14 @@ sats are already in Mostro's wallet. - Both flags true with both bonds present (Phase 5 onward) → both rows in `PendingPayout`. - Non-admin sending `BondResolution` → rejected before processing. +- Confirmed slash → `apply_bond_resolution` returns the slashed row(s) and + the handler sends `Action::BondSlashed` to each slashed party (#768); + `null` payload / no slash → no row, no notice. +- Transient settle failure (bond left `Locked`) → no row returned, no + notice (never untruthful); idempotent admin retry → no row, no + re-notification of the winner. +- Range maker dispute slash → the returned row is the slice child carrying + the proportional amount, not the full parent bond. ### 7.6 Acceptance @@ -992,6 +1018,9 @@ sats are already in Mostro's wallet. one, or both bonds — orthogonal decisions. - Phase 1 behaviour is preserved when the solver omits the payload. - The "Alice scenario" (§15.1) is expressible end-to-end. +- A slashed party receives an explicit `Action::BondSlashed` notice, so a + dispute slash is as transparent as a timeout slash (#768) — no longer + indistinguishable from a no-slash resolution. --- diff --git a/src/app/admin_cancel.rs b/src/app/admin_cancel.rs index 3ab93edb..6dbb9f4a 100644 --- a/src/app/admin_cancel.rs +++ b/src/app/admin_cancel.rs @@ -223,7 +223,11 @@ pub async fn admin_cancel_action( // sides to bonds. Slashed bonds have their hold invoices settled // immediately; the recipient payout to the winning counterparty // is still Phase 3's job. - if let Err(e) = bond::apply_bond_resolution( + // #768: notify each slashed party with a best-effort `BondSlashed` + // forfeiture notice, mirroring the timeout-slash path. Only confirmed + // slashes are returned, so a dropped settle never produces an untruthful + // notice and an idempotent retry never re-notifies. + match bond::apply_bond_resolution( pool, ln_client, &order, @@ -232,10 +236,17 @@ pub async fn admin_cancel_action( ) .await { - tracing::warn!( - order_id = %order.id, - "admin_cancel: bond resolution apply failed: {}", e - ); + Ok(slashed_rows) => { + for slashed in &slashed_rows { + bond::notify_bond_slashed(&order, slashed).await; + } + } + Err(e) => { + tracing::warn!( + order_id = %order.id, + "admin_cancel: bond resolution apply failed: {}", e + ); + } } // Phase 6: a dispute resolution ends the range (no remainder is diff --git a/src/app/admin_settle.rs b/src/app/admin_settle.rs index 507fa39f..f95065aa 100644 --- a/src/app/admin_settle.rs +++ b/src/app/admin_settle.rs @@ -214,7 +214,11 @@ pub async fn admin_settle_action( // payout (asking the winning counterparty for a bolt11, // `send_payment`, retries, forfeiture on the long-stop window) is // still Phase 3's job. - if let Err(e) = bond::apply_bond_resolution( + // #768: notify each slashed party with a best-effort `BondSlashed` + // forfeiture notice, mirroring the timeout-slash path. Only confirmed + // slashes are returned, so a dropped settle never produces an untruthful + // notice and an idempotent retry never re-notifies. + match bond::apply_bond_resolution( pool, ln_client, &order_updated, @@ -223,10 +227,17 @@ pub async fn admin_settle_action( ) .await { - tracing::warn!( - order_id = %order_updated.id, - "admin_settle: bond resolution apply failed: {}", e - ); + Ok(slashed_rows) => { + for slashed in &slashed_rows { + bond::notify_bond_slashed(&order_updated, slashed).await; + } + } + Err(e) => { + tracing::warn!( + order_id = %order_updated.id, + "admin_settle: bond resolution apply failed: {}", e + ); + } } // Phase 6: a dispute resolution ends the range (no remainder is diff --git a/src/app/bond/slash.rs b/src/app/bond/slash.rs index 00a6189d..13e29778 100644 --- a/src/app/bond/slash.rs +++ b/src/app/bond/slash.rs @@ -265,13 +265,20 @@ async fn resolve_slash_target( /// /// `reason` is `LostDispute` in Phase 2 (called from admin handlers); /// Phase 4 (timeout slash) will reuse this helper with `Timeout`. +/// +/// Returns the bond rows whose slash is *confirmed*, so the caller can send +/// each a best-effort `Action::BondSlashed` forfeiture notice (#768). A +/// transient settle failure (bond left `Locked`) yields no row, so the +/// notice is never untruthful; an idempotent admin retry yields none either, +/// so a winner is never re-notified. Range rows carry the slice amount, all +/// others the full bond amount. pub async fn apply_bond_resolution( pool: &Pool, ln_client: &mut L, order: &Order, resolution: &BondResolution, reason: BondSlashReason, -) -> Result<(), MostroError> { +) -> Result, MostroError> { // Active bonds attached to *this* order — i.e. the taker bond(s) on // this slice. The maker bond may live on a range root elsewhere and is // resolved separately via `find_maker_bond_for_order`. @@ -295,6 +302,14 @@ pub async fn apply_bond_resolution( // `is_range` guard instead. let mut slashed_ids: HashSet = HashSet::new(); + // Rows worth a `BondSlashed` forfeiture notice — only the slashes that + // are *confirmed* to have landed, so the caller never tells a user + // their bond was forfeited while the HTLC is still theirs (a transient + // settle failure leaves the bond `Locked`). Non-range rows carry the + // full bond amount; range rows carry the slice's proportional amount. + // Mirrors `slash_or_release_on_timeout`'s `slashed_row` (#768). + let mut notify_rows: Vec = Vec::new(); + for (flag, side) in [ (resolution.slash_seller, Side::Seller), (resolution.slash_buyer, Side::Buyer), @@ -314,14 +329,29 @@ pub async fn apply_bond_resolution( // proportionally per slice — record a child row and leave the // parent HTLC `Locked`. The single settle happens at range close // (`resolve_range_maker_bond_at_close`, called by the admin - // handler right after this returns). + // handler right after this returns). Only notify when this call + // actually inserted the child row — an idempotent re-run must + // not re-notify the maker for a slice already slashed. let root = find_range_root_order(pool, order.clone()).await?; - record_maker_slice_slash(pool, order, &root, &target, reason, node_share_pct).await?; + let inserted = + record_maker_slice_slash(pool, order, &root, &target, reason, node_share_pct) + .await?; + if inserted { + if let Some(child) = find_slice_slash_child(pool, target.id, order.id).await? { + notify_rows.push(child); + } + } } else { // Taker bond, or a non-range maker bond (Phase 2/5): settle the - // HTLC inline. + // HTLC inline. Skip the release sweep regardless of outcome — + // a transient settle failure leaves the bond `Locked` for an + // admin retry, never released. Confirm the slash via the durable + // `slashed_reason` witness before queueing a notice. slash_one(pool, ln_client, &target, reason, node_share_pct).await; slashed_ids.insert(target.id); + if slash_reason_recorded(pool, target.id, reason).await? { + notify_rows.push(target.clone()); + } } } @@ -347,7 +377,7 @@ pub async fn apply_bond_resolution( } } - Ok(()) + Ok(notify_rows) } /// True when the maker bond governing `order` is a **range** bond — i.e. @@ -628,15 +658,16 @@ async fn find_slice_slash_child( .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) } -/// Confirm a timeout slash actually landed on `bond_id`, regardless of -/// where the concurrent payout scheduler has since moved the row. +/// Confirm a slash with the `expected` reason actually landed on `bond_id` +/// by checking the durable `slashed_reason` witness, regardless of where the +/// concurrent payout scheduler has since moved the row. /// -/// The slash CAS in [`slash_one`] writes `slashed_reason = Timeout` -/// atomically with the `Locked → PendingPayout` transition, and **no** -/// later transition clears it: the payout job's state changes +/// The slash CAS in [`slash_one`] writes `slashed_reason` atomically with +/// the `Locked → PendingPayout` transition, and **no** later transition +/// clears it: the payout job's state changes /// (`PendingPayout → Slashed | Forfeited | Failed`, and the /// `Failed → PendingPayout` resurrection) only ever rewrite `state`, never -/// `slashed_reason` / `slashed_at`. So `slashed_reason = Timeout` is a +/// `slashed_reason` / `slashed_at`. So the recorded `slashed_reason` is a /// stable witness that *this* slash succeeded. /// /// A point-in-time `state = PendingPayout` check would be racy: the payout @@ -647,20 +678,31 @@ async fn find_slice_slash_child( /// forfeiture notice is never lost to that race. /// /// A transient settle failure leaves the bond `Locked` with -/// `slashed_reason` NULL, so this still returns `false` and no false -/// forfeiture notice is sent. Dispute slashes write -/// `slashed_reason = LostDispute`, so a (vanishingly unlikely) concurrent -/// dispute slash that won the CAS first does not trigger a *timeout* -/// notice here — the dispute path owns its own messaging. -async fn timeout_slash_confirmed(pool: &Pool, bond_id: Uuid) -> Result { +/// `slashed_reason` NULL, so this returns `false` and no false forfeiture +/// notice is sent. The `expected` reason is matched exactly, so a timeout +/// caller never confirms on a dispute slash's `LostDispute` witness and +/// vice-versa — each slash path owns its own messaging. +async fn slash_reason_recorded( + pool: &Pool, + bond_id: Uuid, + expected: BondSlashReason, +) -> Result { let row: Option<(Option,)> = sqlx::query_as("SELECT slashed_reason FROM bonds WHERE id = ?") .bind(bond_id) .fetch_optional(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - let timeout = BondSlashReason::Timeout.to_string(); - Ok(row.and_then(|(reason,)| reason).as_deref() == Some(timeout.as_str())) + let expected = expected.to_string(); + Ok(row.and_then(|(reason,)| reason).as_deref() == Some(expected.as_str())) +} + +/// Confirm a *timeout* slash landed on `bond_id`. Thin wrapper over +/// [`slash_reason_recorded`] keyed on `BondSlashReason::Timeout`; see that +/// function for why the durable `slashed_reason` witness is used instead of +/// a transient `state = PendingPayout` check. +async fn timeout_slash_confirmed(pool: &Pool, bond_id: Uuid) -> Result { + slash_reason_recorded(pool, bond_id, BondSlashReason::Timeout).await } /// Phase 4 — best-effort forfeiture notice to the slashed user @@ -3816,4 +3858,248 @@ mod tests { "refund = bond - Σ slice slashes (absorbs the rounding remainder)" ); } + + // ── #768 — dispute-slash forfeiture notice (apply return value) ────────── + + #[tokio::test] + async fn apply_slash_buyer_returns_confirmed_taker_row() { + // A confirmed taker-bond slash is returned so the caller can send a + // BondSlashed notice carrying the full bond amount. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert_eq!(slashed.len(), 1, "exactly the buyer's bond is returned"); + assert_eq!(slashed[0].id, bond.id); + assert_eq!( + slashed[0].amount_sats, bond.amount_sats, + "full bond amount carried for the notice" + ); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::PendingPayout.to_string() + ); + } + + #[tokio::test] + async fn apply_slash_both_returns_two_confirmed_rows() { + // Both bonds slashed → both returned, so both parties are notified. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: true, + }; + + let mut slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + slashed.sort_by_key(|b| b.id); + let mut expected = vec![maker_bond.id, taker_bond.id]; + expected.sort(); + assert_eq!( + slashed.iter().map(|b| b.id).collect::>(), + expected, + "both slashed bonds are returned" + ); + } + + #[tokio::test] + async fn apply_null_payload_returns_no_rows() { + // No slash directive → nothing to notify; the bond is released. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: false, + }; + + let slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert!(slashed.is_empty(), "no slash → no notice"); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::Released.to_string() + ); + } + + #[tokio::test] + async fn apply_transient_settle_failure_returns_no_row() { + // Load-bearing: a transient settle failure leaves the bond Locked, + // so the slash is unconfirmed and MUST NOT be returned — the caller + // never tells a user their bond was forfeited while the HTLC is + // still theirs. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + ln.fail_next_with("code=Unavailable: connection refused"); + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let slashed = + apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + + assert!(slashed.is_empty(), "unconfirmed slash yields no notice"); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::Locked.to_string(), + "bond stays Locked for an admin retry" + ); + } + + #[tokio::test] + async fn apply_idempotent_retry_returns_no_row() { + // Non-range admin retry: the first apply slashes and reports the + // bond; a second apply finds it already PendingPayout (no longer + // active), so nothing is returned and the winner is never + // re-notified. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let first = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + assert_eq!(first.len(), 1); + + let second = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + assert!( + second.is_empty(), + "retry on a PendingPayout bond yields no notice" + ); + } + + #[tokio::test] + async fn apply_range_maker_slash_returns_child_with_slice_amount() { + // Phase 6 × #768: a dispute slash of a range maker bond returns the + // child slice row (slice fiat 40 / max 100 × 1000 = 400), not the + // full parent bond, and leaves the parent HTLC Locked. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let mut ln = StubSettle::new(); + + let slashed = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + + assert_eq!(slashed.len(), 1); + assert_eq!(slashed[0].parent_bond_id, Some(parent.id)); + assert_eq!(slashed[0].child_order_id, Some(root.id)); + assert_eq!(slashed[0].amount_sats, 400, "slice share, not full bond"); + assert_eq!( + slashed[0].pubkey, + maker_pk(), + "the maker is the slashed party" + ); + assert!( + ln.calls().is_empty(), + "parent HTLC is settled only at range close, never inline" + ); + assert_eq!( + read_bond_state(&pool, parent.id).await, + BondState::Locked.to_string() + ); + } + + #[tokio::test] + async fn apply_range_maker_slash_idempotent_retry_returns_no_row() { + // A re-applied dispute resolution (admin retry) must not re-notify: + // the slice child already exists, so record_maker_slice_slash inserts + // nothing and no row is returned. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let mut ln = StubSettle::new(); + + let first = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + assert_eq!(first.len(), 1, "first apply records and reports the slice"); + + let second = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + assert!(second.is_empty(), "retry must not re-notify the maker"); + } } From 23b759b3d67746b34b52119d5c6e0ba6f72347f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Wed, 17 Jun 2026 16:08:50 +0200 Subject: [PATCH 16/23] =?UTF-8?q?feat(transport):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20anti-spam=20gates=20for=20protocol=20v2=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 2 of docs/TRANSPORT_V2_SPEC.md (§6, issue #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) + 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) + `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) --- docs/TRANSPORT_V2_SPEC.md | 51 ++++++--- settings.tpl.toml | 13 +++ src/app.rs | 39 +++++++ src/config/types.rs | 89 +++++++++++++++ src/db.rs | 211 ++++++++++++++++++++++++++++++++++ src/main.rs | 24 ++++ src/scheduler.rs | 30 +++++ src/spam_gate.rs | 234 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 677 insertions(+), 14 deletions(-) create mode 100644 src/spam_gate.rs diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index 752132d8..6832c483 100644 --- a/docs/TRANSPORT_V2_SPEC.md +++ b/docs/TRANSPORT_V2_SPEC.md @@ -1,6 +1,6 @@ # Transport v2 — NIP-44 Direct Messaging (Protocol v2) -**Status:** Phase 1 implemented (this spec ships with it) · Phases 2–4 pending +**Status:** Phases 1–2 implemented · Phases 3–4 pending **Issue:** [#626 — Messaging Transport Abstraction Layer](https://github.com/MostroP2P/mostro/issues/626) **Full proposal:** [issue comment](https://github.com/MostroP2P/mostro/issues/626#issuecomment-4694164653) **Core implementation:** [mostro-core#152](https://github.com/MostroP2P/mostro-core/pull/152), released in mostro-core **0.13.0** (`transport` module) @@ -193,19 +193,42 @@ Minimal daemon integration; **zero handler changes** by design: when the caller didn't pass one. - `src/nip33.rs` — `protocol_versions` tag in the kind-38385 info event. -### Phase 2 — anti-spam gates (PENDING — daemon-only, the payoff) - -The reason v2 exists: reject junk *before* paying decrypt/parse costs. - -- Cache of active trade pubkeys (open orders/disputes), refreshed on state - changes. -- Cheap pre-validation in the event loop for kind 14: check `event.pubkey` - against the cache **before** decrypting. -- Two lanes — this is the necessary nuance to "only accept known keys": - brand-new orders and takes arrive from keys Mostro has never seen, so - there is a *known-keys lane* (pre-validated, cheap) and a *first-contact - lane* (where spam lives; PoW + relay rate-limiting apply there). -- TTL / stale-event rejection and dedup as defense in depth. +### Phase 2 — anti-spam gates (DONE — this change; daemon-only, the payoff) + +The reason v2 exists: reject junk *before* paying decrypt/parse costs. All of +the following are **v2-only** — the gate is skipped on the `gift-wrap` +transport, whose outer key is a throwaway with no pre-validatable signal. + +- **Active-trade-pubkey cache** (`src/spam_gate.rs`, `SpamGate`): the trade + keys that may legitimately message Mostro now — buyer/seller/creator of + every non-terminal order, plus the solver of every active dispute. Built by + `db::find_active_trade_pubkeys` (terminal set = the restore-session + `EXCLUDED_ORDER_STATUSES` **minus `'dispute'`**, so disputed orders stay + active). Warmed at startup in `main.rs` and rebuilt every + `active_pubkeys_refresh_interval` seconds (default 60) by + `scheduler::job_refresh_active_pubkeys` — a periodic full reload, chosen + because status mutations are scattered across handlers with no single + choke-point. Global-singleton (`OnceLock`), mirroring `PriceManager`. +- **Cheap pre-validation in the event loop** (`src/app.rs`), for kind 14, + **before** `unwrap_incoming` decrypts: check `event.pubkey` against the + cache. +- **Two lanes** — the necessary nuance to "only accept known keys": brand-new + orders and takes arrive from keys Mostro has never seen. + - *Known-keys lane:* sender in the cache → fast-path; only the base `pow` + (already checked at the top of the loop) applies. + - *First-contact lane:* sender unseen → must clear `pow_first_contact` + (`[mostro]`, defaults to `pow` so existing configs are unchanged) before + the daemon decrypts. This is where spam concentrates; PoW here plus + relay-side rate limiting are the toll. +- **Dedup as defense in depth:** a `REPLAY_WINDOW_SECS` (60 s) guard drops a + re-sent identical event id before decryption. The existing 10-second + freshness window (post-decrypt, on the inner `created_at`) still applies as + the precise stale-event check. + +New config (`[mostro]`): `pow_first_contact` (`Option`, default = `pow`) +and `active_pubkeys_refresh_interval` (default 60). Both `#[serde(default)]`, +so pre-Phase-2 `settings.toml` files are wire-identical. Zero handler changes; +the gate sits entirely in the event-loop preamble. ### Phase 3 — protocol docs + client migration (PENDING) diff --git a/settings.tpl.toml b/settings.tpl.toml index 0045fc29..a8c9de5b 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -60,6 +60,19 @@ pow = 0 # your community uses support protocol v2. # See docs/TRANSPORT_V2_SPEC.md transport = "gift-wrap" +# Anti-spam gate for the "nip44" transport (docs/TRANSPORT_V2_SPEC.md §6 +# Phase 2). Proof-of-work (leading-zero bits) demanded of a *first-contact* +# event — one whose visible sender (trade key) is not part of an active +# order/dispute — checked BEFORE decryption. Ongoing trades (known keys) need +# only `pow`; brand-new orders/takes from unseen keys must clear this stiffer +# toll. Omit (or leave commented) to fall back to `pow`. No effect on +# "gift-wrap". Example: keep pow = 0 for cheap ongoing trades but require work +# from first-contact senders. +# pow_first_contact = 16 +# How often (seconds) to rebuild the active-trade-pubkey cache the gate +# consults. Lower = a just-taken order's keys fast-path sooner; higher = less +# DB load. Default 60. +# active_pubkeys_refresh_interval = 60 # Publish mostro info interval publish_mostro_info_interval = 300 # Bitcoin price API base URL. diff --git a/src/app.rs b/src/app.rs index fd8afbfc..3ae0c190 100644 --- a/src/app.rs +++ b/src/app.rs @@ -305,6 +305,13 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { // NIP-44 direct); events of any other kind are dropped before any // decryption work. See docs/TRANSPORT_V2_SPEC.md. let accepted_kind = ctx.settings().mostro.transport.event_kind(); + // Phase 2 anti-spam gate (docs/TRANSPORT_V2_SPEC.md §6): on the v2 (kind + // 14) transport the visible author is the trade key, so the daemon can + // pre-validate before decrypting. Unknown (first-contact) senders must + // clear `pow_first_contact`; known active-trade keys need only `pow`. The + // gate is meaningless for v1 (gift wraps are signed by throwaway keys). + let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact(); + let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND; loop { let mut notifications = client.notifications(); @@ -318,6 +325,38 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { continue; } if event.kind == accepted_kind { + // Phase 2 anti-spam gate (protocol v2 / kind 14 only): + // cheap pre-validation BEFORE paying the NIP-44 decrypt + // cost. v1 gift wraps skip this — their outer key is a + // throwaway with no pre-validatable signal. + if is_v2 { + if let Some(gate) = crate::spam_gate::SpamGate::global() { + let now = chrono::Utc::now().timestamp(); + // Dedup: drop a re-sent identical event (defense in + // depth against replay floods). + if gate.is_replay(event.id, now) { + tracing::debug!("Dropping replayed event {}", event.id); + continue; + } + // Two lanes: a sender already in an active trade is + // fast-pathed (only the base `pow` already checked + // above applies); an unseen first-contact sender + // must clear the stiffer `pow_first_contact` before + // we decrypt. New orders/takes legitimately arrive + // here — so does spam, hence the PoW toll. + if !gate.is_known(&event.pubkey.to_string()) + && !event.check_pow(pow_first_contact) + { + tracing::info!( + "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", + event.pubkey, + pow_first_contact + ); + continue; + } + } + } + // Validate event signature if event.verify().is_err() { tracing::warn!("Error in event verification") diff --git a/src/config/types.rs b/src/config/types.rs index f3ea77c3..3cc26cef 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -239,6 +239,62 @@ mod tests { assert_eq!(MostroSettings::default().transport, Transport::GiftWrap); } + #[test] + fn pow_first_contact_defaults_to_base_pow() { + // Unset ⇒ first-contact gate uses the base `pow`, so a config that + // predates Phase 2 behaves exactly as before (spec §6 Phase 2). + let s = MostroSettings { + pow: 8, + ..MostroSettings::default() + }; + assert_eq!(s.pow_first_contact, None); + assert_eq!(s.effective_pow_first_contact(), 8); + } + + #[test] + fn pow_first_contact_override_takes_precedence() { + let s = MostroSettings { + pow: 4, + pow_first_contact: Some(20), + ..MostroSettings::default() + }; + assert_eq!(s.effective_pow_first_contact(), 20); + } + + #[test] + fn active_pubkeys_refresh_interval_defaults_to_60() { + assert_eq!( + MostroSettings::default().active_pubkeys_refresh_interval, + 60 + ); + } + + #[test] + fn mostro_settings_omitting_phase2_keys_parses() { + // An existing settings.toml without the Phase 2 keys must still + // deserialize (both fields are `#[serde(default)]`). + let toml_str = r#" +fee = 0 +max_routing_fee = 0.002 +max_order_amount = 1000000 +min_payment_amount = 100 +expiration_hours = 24 +expiration_seconds = 900 +user_rates_sent_interval_seconds = 3600 +max_expiration_days = 15 +publish_relays_interval = 60 +pow = 0 +publish_mostro_info_interval = 300 +fiat_currencies_accepted = ["USD"] +max_orders_per_response = 10 +dev_fee_percentage = 0.30 +"#; + let s: MostroSettings = toml::from_str(toml_str).expect("legacy config parses"); + assert_eq!(s.pow_first_contact, None); + assert_eq!(s.active_pubkeys_refresh_interval, 60); + assert_eq!(s.effective_pow_first_contact(), 0); + } + #[test] fn dispute_kind_falls_back_to_90_when_unconfigured() { let settings = ExpirationSettings::default(); @@ -397,6 +453,33 @@ pub struct MostroSettings { /// A node speaks exactly one. See docs/TRANSPORT_V2_SPEC.md. #[serde(default)] pub transport: Transport, + /// Proof-of-work difficulty (leading-zero bits) demanded of a + /// *first-contact* event on the protocol-v2 (`nip44`) transport — one + /// whose visible sender (trade key) is **not** in the active-trade + /// cache — checked BEFORE the daemon pays the NIP-44 decrypt cost. This + /// is the Phase 2 anti-spam lane: ongoing trades (known keys) need only + /// `pow`, while brand-new orders/takes from unseen keys must grind this + /// harder toll (see docs/TRANSPORT_V2_SPEC.md §6 Phase 2). + /// + /// `None` ⇒ falls back to `pow`, so existing configs and the v1 + /// transport are wire-identical to before. Has no effect on `gift-wrap` + /// (v1 senders are throwaway keys that can't be pre-validated). + #[serde(default)] + pub pow_first_contact: Option, + /// How often (seconds) to rebuild the active-trade-pubkey cache that the + /// Phase 2 anti-spam gate consults. Lower = fresher known-keys set (a + /// just-taken order's keys fast-path sooner); higher = less DB load. + #[serde(default = "default_active_pubkeys_refresh_interval")] + pub active_pubkeys_refresh_interval: u64, +} + +impl MostroSettings { + /// Effective first-contact PoW difficulty: the explicit + /// `pow_first_contact` when set, otherwise the base `pow`. Centralised so + /// the event loop and tests agree on the fallback (spec §6 Phase 2). + pub fn effective_pow_first_contact(&self) -> u8 { + self.pow_first_contact.unwrap_or(self.pow) + } } fn default_bitcoin_price_api_url() -> String { @@ -413,6 +496,10 @@ fn default_exchange_rates_update_interval() -> u64 { 300 // 5 minutes } +fn default_active_pubkeys_refresh_interval() -> u64 { + 60 // 1 minute — keeps a just-taken order's keys fast-pathing promptly +} + impl Default for MostroSettings { fn default() -> Self { Self { @@ -443,6 +530,8 @@ impl Default for MostroSettings { publish_exchange_rates_to_nostr: default_publish_exchange_rates(), exchange_rates_update_interval_seconds: default_exchange_rates_update_interval(), transport: Transport::default(), + pow_first_contact: None, + active_pubkeys_refresh_interval: default_active_pubkeys_refresh_interval(), } } } diff --git a/src/db.rs b/src/db.rs index 7b4c884e..20183ddc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,6 +5,7 @@ use nostr_sdk::prelude::*; use sqlx::pool::Pool; use sqlx::sqlite::SqliteRow; use sqlx::{Row, Sqlite, SqlitePool}; +use std::collections::HashSet; use std::fs::{set_permissions, Permissions}; use std::path::Path; use std::sync::Arc; @@ -14,9 +15,69 @@ use uuid::Uuid; const EXCLUDED_ORDER_STATUSES: &str = "'expired','success','canceled','dispute','canceledbyadmin','completedbyadmin','settledbyadmin','cooperativelycanceled'"; const ACTIVE_DISPUTE_STATUSES: &str = "'initiated','in-progress'"; +/// Terminal order statuses for the Phase 2 active-trade-pubkey cache: an +/// order in any of these will never legitimately originate further trade-key +/// messages, so its participants drop out of the "known keys" set. +/// +/// This is deliberately [`EXCLUDED_ORDER_STATUSES`] **minus `'dispute'`** — a +/// disputed order is still active (buyer, seller and the assigned solver keep +/// messaging), so its trade keys must stay fast-pathed. See +/// `find_active_trade_pubkeys` and docs/TRANSPORT_V2_SPEC.md §6 Phase 2. +const TERMINAL_ORDER_STATUSES: &str = "'expired','success','canceled','canceledbyadmin','completedbyadmin','settledbyadmin','cooperativelycanceled'"; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +/// Collect the "known keys" the Phase 2 anti-spam gate fast-paths: the trade +/// pubkeys (buyer / seller / creator) of every **non-terminal** order, plus +/// the solver pubkey of every **active** dispute (spec §6 Phase 2). +/// +/// Status lists are compile-time constants ([`TERMINAL_ORDER_STATUSES`], +/// [`ACTIVE_DISPUTE_STATUSES`]), never user input, so the inline +/// interpolation carries no injection risk — same pattern the restore-session +/// queries already use. The result is deduplicated. +pub async fn find_active_trade_pubkeys(pool: &SqlitePool) -> Result, MostroError> { + let mut keys: HashSet = HashSet::new(); + + // Order participants of every still-active order (disputed orders + // included — `TERMINAL_ORDER_STATUSES` excludes `'dispute'`). + let order_query = format!( + "SELECT buyer_pubkey, seller_pubkey, creator_pubkey FROM orders WHERE status NOT IN ({TERMINAL_ORDER_STATUSES})" + ); + let order_rows = sqlx::query(&order_query) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + for row in order_rows { + for col in ["buyer_pubkey", "seller_pubkey", "creator_pubkey"] { + if let Ok(Some(pk)) = row.try_get::, _>(col) { + if !pk.is_empty() { + keys.insert(pk); + } + } + } + } + + // Assigned solvers of active disputes (so admin-settle/cancel/take from + // the solver's key fast-paths instead of hitting the first-contact lane). + let dispute_query = format!( + "SELECT solver_pubkey FROM disputes WHERE status IN ({ACTIVE_DISPUTE_STATUSES}) AND solver_pubkey IS NOT NULL" + ); + let dispute_rows = sqlx::query(&dispute_query) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + for row in dispute_rows { + if let Ok(Some(pk)) = row.try_get::, _>("solver_pubkey") { + if !pk.is_empty() { + keys.insert(pk); + } + } + } + + Ok(keys.into_iter().collect()) +} + /// Helper function to rebuild disputes table without token columns when DROP COLUMN is unsupported. async fn rebuild_disputes_table_without_tokens(pool: &SqlitePool) -> Result<(), MostroError> { tracing::info!("Rebuilding disputes table without token columns (SQLite compatibility mode)"); @@ -1407,6 +1468,156 @@ mod tests { .unwrap(); } + /// Insert an order carrying explicit trade pubkeys, for the Phase 2 + /// active-trade-pubkey cache query. + async fn insert_order_with_pubkeys( + pool: &SqlitePool, + id: uuid::Uuid, + status: &str, + creator: Option<&str>, + buyer: Option<&str>, + seller: Option<&str>, + ) { + sqlx::query( + r#" + INSERT INTO orders (id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, created_at, expires_at, + creator_pubkey, buyer_pubkey, seller_pubkey) + VALUES (?1, 'buy', 'event123', ?2, 0, 'lightning', + 100000, 'USD', 100, 1700000000, 1700086400, ?3, ?4, ?5) + "#, + ) + .bind(id) + .bind(status) + .bind(creator) + .bind(buyer) + .bind(seller) + .execute(pool) + .await + .unwrap(); + } + + async fn setup_disputes_table(pool: &SqlitePool) { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS disputes ( + id char(36) primary key not null, + order_id char(36) unique not null, + status varchar(10) not null, + order_previous_status varchar(10) not null, + solver_pubkey char(64), + created_at integer not null, + taken_at integer default 0 + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + } + + /// Phase 2 (docs/TRANSPORT_V2_SPEC.md §6): the active-trade-pubkey cache + /// must include participants of every non-terminal order — **including + /// disputed ones** — and the solvers of active disputes, while excluding + /// terminal orders and resolved disputes. + #[tokio::test] + async fn find_active_trade_pubkeys_covers_active_and_disputed_excludes_terminal() { + let pool = setup_orders_db().await.unwrap(); + setup_disputes_table(&pool).await; + + // Active order — all three keys are "known". + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "waiting-payment", + Some("creator_active"), + Some("buyer_active"), + Some("seller_active"), + ) + .await; + // Disputed order — still active (the load-bearing nuance: 'dispute' is + // NOT in TERMINAL_ORDER_STATUSES), so its keys must be included. + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "dispute", + Some("creator_disp"), + Some("buyer_disp"), + Some("seller_disp"), + ) + .await; + // Terminal orders — excluded. + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "success", + Some("creator_succ"), + Some("buyer_succ"), + Some("seller_succ"), + ) + .await; + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "canceled", + Some("creator_canc"), + None, + None, + ) + .await; + + // Active dispute with an assigned solver → solver key included. + sqlx::query( + "INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at) \ + VALUES (?1, ?2, 'in-progress', 'fiat-sent', 'solver_active', 1700000000)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + // Resolved dispute → its solver is NOT included. + sqlx::query( + "INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at) \ + VALUES (?1, ?2, 'settled', 'fiat-sent', 'solver_settled', 1700000000)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + + let keys: HashSet = super::find_active_trade_pubkeys(&pool) + .await + .unwrap() + .into_iter() + .collect(); + + for k in [ + "creator_active", + "buyer_active", + "seller_active", + "creator_disp", + "buyer_disp", + "seller_disp", + "solver_active", + ] { + assert!(keys.contains(k), "{k} should be a known active key"); + } + for k in [ + "creator_succ", + "buyer_succ", + "seller_succ", + "creator_canc", + "solver_settled", + ] { + assert!( + !keys.contains(k), + "{k} must NOT be known (terminal/resolved)" + ); + } + } + #[tokio::test] async fn test_fetch_string_column_scalar() { let pool = setup_db().await.unwrap(); diff --git a/src/main.rs b/src/main.rs index 4b875383..b33a356e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ pub mod nip33; pub mod price; pub mod rpc; pub mod scheduler; +pub mod spam_gate; pub mod util; use crate::app::context::AppContext; @@ -171,6 +172,29 @@ async fn main() -> Result<()> { }); } + // Install the protocol-v2 anti-spam gate and warm its active-trade-pubkey + // cache before the event loop starts, so the very first kind-14 events are + // already pre-filtered against known keys (spec §6 Phase 2). The cache is + // kept fresh afterwards by `job_refresh_active_pubkeys`. Inert on the v1 + // (gift-wrap) transport, which never consults the gate. + { + use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + match db::find_active_trade_pubkeys(get_db_pool().as_ref()).await { + Ok(keys) => { + tracing::info!( + "SpamGate: warming active-trade-pubkey cache ({} keys)", + keys.len() + ); + gate.set_known(keys); + } + Err(e) => tracing::warn!("SpamGate: initial cache warm failed: {e}"), + } + if gate.install_global().is_err() { + tracing::warn!("SpamGate already installed"); + } + } + // Build AppContext explicitly with all dependencies let settings = Arc::new( MOSTRO_CONFIG diff --git a/src/scheduler.rs b/src/scheduler.rs index ca823511..74ec62f2 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -36,10 +36,40 @@ pub async fn start_scheduler(ctx: AppContext) { job_relay_list(ctx.clone()).await; job_update_bitcoin_prices().await; job_flush_messages_queue(ctx.clone()).await; + job_refresh_active_pubkeys(ctx.clone()).await; info!("Scheduler Started"); } +/// Periodically rebuild the protocol-v2 anti-spam gate's active-trade-pubkey +/// cache from the DB (spec §6 Phase 2). Status mutations are scattered across +/// many handlers with no single choke-point, so a periodic full reload is the +/// robust, low-coupling refresh strategy: a just-taken order's keys begin +/// fast-pathing within one `active_pubkeys_refresh_interval`. Inert on the v1 +/// transport (the event loop only consults the gate for kind-14 events). +async fn job_refresh_active_pubkeys(ctx: AppContext) { + let interval = ctx.settings().mostro.active_pubkeys_refresh_interval.max(1); + tokio::spawn(async move { + loop { + match find_active_trade_pubkeys(ctx.pool()).await { + Ok(keys) => { + if let Some(gate) = crate::spam_gate::SpamGate::global() { + let n = keys.len(); + gate.set_known(keys); + tracing::debug!( + "spam_gate: refreshed active-trade-pubkey cache ({n} keys)" + ); + } + } + Err(e) => { + warn!("spam_gate: failed to refresh active-trade-pubkey cache: {e}") + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + } + }); +} + async fn job_flush_messages_queue(ctx: AppContext) { // Clone for closure owning with Arc let order_msg_list = MESSAGE_QUEUES.queue_order_msg.clone(); diff --git a/src/spam_gate.rs b/src/spam_gate.rs new file mode 100644 index 00000000..5be5b841 --- /dev/null +++ b/src/spam_gate.rs @@ -0,0 +1,234 @@ +//! Protocol-v2 anti-spam gate (spec §6 Phase 2, docs/TRANSPORT_V2_SPEC.md). +//! +//! The whole point of the kind-14 transport is that the visible sender is the +//! authoring **trade key**, so the daemon can pre-validate cheaply *before* +//! paying the NIP-44 decrypt cost. This module holds the two in-memory pieces +//! that make that work: +//! +//! 1. **Active-trade-pubkey cache** — the set of trade keys that legitimately +//! message Mostro right now (participants of non-terminal orders + active +//! dispute solvers). Rebuilt periodically from the DB by a scheduler job +//! (`job_refresh_active_pubkeys`) and warmed once at startup. +//! 2. **Replay guard** — a short-window dedup of seen event ids, so a flood of +//! re-sent identical events is dropped before decryption (defense in depth). +//! +//! The gate has **two lanes**: a *known-keys lane* (sender in the cache → +//! fast-path, only the base `pow` applies) and a *first-contact lane* (sender +//! unseen → must clear the stiffer `pow_first_contact` before the daemon +//! decrypts). Brand-new orders and takes legitimately arrive on the +//! first-contact lane; that is also where spam concentrates, so PoW (plus +//! relay-side rate limiting) is the toll there. +//! +//! Only the v2 (`nip44`) transport uses this gate — v1 gift wraps are authored +//! by throwaway keys that carry no pre-validatable signal. +//! +//! Follows the established global-singleton pattern (`OnceLock`, like +//! `PRICE_MANAGER` / `MOSTRO_CONFIG`); the cache is an inner `RwLock` and the +//! replay guard a `Mutex`, matching the daemon's single-consumer event loop. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock, RwLock}; + +use nostr_sdk::EventId; + +/// How long (seconds) a seen event id is remembered for replay dedup. Must be +/// ≥ the event loop's 10-second freshness window so a duplicate can never slip +/// past the guard while the original is still acceptable; 60s adds margin for +/// clock skew without unbounded memory (entries are pruned past this age). +pub const REPLAY_WINDOW_SECS: i64 = 60; + +/// Process-wide gate. `None` until [`SpamGate::install_global`] runs in `main`; +/// the event loop treats an absent gate as fail-open (no pre-filtering), so +/// unit tests that never install it are unaffected. +static SPAM_GATE: OnceLock = OnceLock::new(); + +/// Why [`SpamGate::install_global`] refused. A zero-size enum (not the bulky +/// `SpamGate`) so the `Result` stays small; mirrors `price::InstallError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallError { + /// A gate is already installed. + AlreadyInstalled, +} + +/// Short-window dedup of event ids (defense in depth, §6 Phase 2). +struct ReplayGuard { + seen: HashMap, + window_secs: i64, +} + +impl ReplayGuard { + fn new(window_secs: i64) -> Self { + Self { + seen: HashMap::new(), + window_secs, + } + } + + /// Record `id` as seen at `now` and report whether it was **already** + /// present within the window (i.e. a replay the caller should drop). + /// Expired entries are pruned on the way through so the map stays bounded + /// by the in-window event rate. + fn check_and_record(&mut self, id: EventId, now: i64) -> bool { + let cutoff = now - self.window_secs; + self.seen.retain(|_, &mut seen_at| seen_at >= cutoff); + // `insert` returns the previous value if the key was present — a + // non-expired prior sighting means this is a replay. + self.seen.insert(id, now).is_some() + } +} + +/// The anti-spam gate: known-keys cache + replay guard. +pub struct SpamGate { + known: RwLock>, + replay: Mutex, +} + +impl SpamGate { + /// Build an empty gate with the given replay window. + pub fn new(replay_window_secs: i64) -> Self { + Self { + known: RwLock::new(HashSet::new()), + replay: Mutex::new(ReplayGuard::new(replay_window_secs)), + } + } + + /// Install as the process-wide gate. Mirrors `PriceManager::install_global`: + /// a second call returns `Err(AlreadyInstalled)` rather than panicking. + /// (The large `SpamGate` is dropped on the error path rather than returned, + /// keeping the `Result` small — clippy `result_large_err`.) + pub fn install_global(self) -> Result<(), InstallError> { + SPAM_GATE + .set(self) + .map_err(|_| InstallError::AlreadyInstalled) + } + + /// Borrow the installed gate, if any. `None` ⇒ not installed (fail-open). + pub fn global() -> Option<&'static SpamGate> { + SPAM_GATE.get() + } + + /// Replace the known-keys set wholesale with the latest snapshot from the + /// DB. A poisoned lock is logged and skipped — a stale cache only costs a + /// few legitimate keys a trip through the first-contact lane, never a + /// crash. + pub fn set_known>(&self, keys: I) { + match self.known.write() { + Ok(mut set) => { + *set = keys.into_iter().collect(); + } + Err(_) => tracing::error!("spam_gate: known-keys lock poisoned; skipping refresh"), + } + } + + /// Is `pubkey` (hex trade key) a currently-active participant? A poisoned + /// lock degrades to `false` (treat as first-contact) — the safe direction: + /// the sender just pays the PoW toll instead of being waved through. + pub fn is_known(&self, pubkey: &str) -> bool { + self.known + .read() + .map(|set| set.contains(pubkey)) + .unwrap_or(false) + } + + /// Number of cached active keys (diagnostics / tests). + pub fn known_count(&self) -> usize { + self.known.read().map(|set| set.len()).unwrap_or(0) + } + + /// Record `id` and report whether it is a replay to drop. A poisoned lock + /// degrades to `false` (never drop a real message because dedup state was + /// lost). + pub fn is_replay(&self, id: EventId, now: i64) -> bool { + match self.replay.lock() { + Ok(mut guard) => guard.check_and_record(id, now), + Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::{EventBuilder, Keys}; + + fn an_event_id(note: &str) -> EventId { + EventBuilder::text_note(note) + .sign_with_keys(&Keys::generate()) + .expect("sign test event") + .id + } + + #[test] + fn known_set_membership_and_replace() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + assert!(!gate.is_known("a")); + gate.set_known(["a".to_string(), "b".to_string()]); + assert!(gate.is_known("a")); + assert!(gate.is_known("b")); + assert!(!gate.is_known("c")); + assert_eq!(gate.known_count(), 2); + + // set_known replaces wholesale (a refreshed DB snapshot, not a merge): + // a key no longer active drops out. + gate.set_known(["c".to_string()]); + assert!(gate.is_known("c")); + assert!(!gate.is_known("a")); + assert_eq!(gate.known_count(), 1); + } + + #[test] + fn replay_first_seen_then_duplicate() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let id = an_event_id("dup"); + let now = 1_000_000; + assert!(!gate.is_replay(id, now), "first sighting is not a replay"); + assert!( + gate.is_replay(id, now + 1), + "second sighting within window is a replay" + ); + assert!( + gate.is_replay(id, now + 30), + "still a replay later in the window" + ); + } + + #[test] + fn distinct_ids_are_independent() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let a = an_event_id("a"); + let b = an_event_id("b"); + let now = 500; + assert!(!gate.is_replay(a, now)); + assert!(!gate.is_replay(b, now), "a different id is not a replay"); + assert!(gate.is_replay(a, now), "but re-seeing a is"); + } + + #[test] + fn entry_expires_after_window() { + let mut guard = ReplayGuard::new(60); + let id = an_event_id("expire"); + assert!(!guard.check_and_record(id, 1_000)); + // Past the window the prior sighting is pruned, so it reads as fresh. + assert!(!guard.check_and_record(id, 1_000 + 61)); + // ...and is tracked again from the new timestamp. + assert!(guard.check_and_record(id, 1_000 + 61)); + } + + #[test] + fn prune_keeps_map_bounded_to_window() { + let mut guard = ReplayGuard::new(60); + for i in 0..100 { + // Each a distinct id at a distinct, steadily-advancing time. + guard.check_and_record(an_event_id(&format!("e{i}")), 10_000 + i); + } + // Advancing well past the window and touching the guard prunes every + // stale entry, leaving only the one just recorded. + let fresh = an_event_id("fresh"); + guard.check_and_record(fresh, 10_000 + 100 + 61); + assert_eq!( + guard.seen.len(), + 1, + "stale entries must be pruned, leaving only the fresh sighting" + ); + } +} From 4c8259bd1835b41ae9c5dc776b75cee2764e2b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Wed, 17 Jun 2026 16:12:53 +0200 Subject: [PATCH 17/23] feat(transport): log active transport at mostrod startup (#781) 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) --- src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index b33a356e..09bd8bf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,9 +77,16 @@ async fn main() -> Result<()> { // Subscribe only to the configured transport's kind: 1059 (protocol v1 // gift wrap) or 14 (protocol v2 NIP-44 direct). See docs/TRANSPORT_V2_SPEC.md. + let transport = Settings::get_mostro().transport; + tracing::info!( + "Transport: {} (protocol v{}, event kind {})", + transport, + transport.protocol_version(), + transport.event_kind().as_u16() + ); let subscription = Filter::new() .pubkey(mostro_keys.public_key()) - .kind(Settings::get_mostro().transport.event_kind()) + .kind(transport.event_kind()) .limit(0); let client = match get_nostr_client() { From 5801afec141e16e5d2d9bfad3942edbc694ec677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Wed, 17 Jun 2026 21:46:10 +0200 Subject: [PATCH 18/23] fix(nip33): rename info tag protocol_versions -> protocol_version (#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) --- docs/TRANSPORT_V2_SPEC.md | 8 ++++---- src/nip33.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index 6832c483..7fbca5f8 100644 --- a/docs/TRANSPORT_V2_SPEC.md +++ b/docs/TRANSPORT_V2_SPEC.md @@ -138,7 +138,7 @@ dm_days = 30 | `nip44` | 14 (v2) | v2-capable clients only — the only mode from v0.19.0 | **Capability discovery:** the node advertises its protocol in the kind -`38385` instance-info event with a `protocol_versions` tag (`"1"` or +`38385` instance-info event with a `protocol_version` tag (`"1"` or `"2"`, derived from `transport`). Old clients ignore the unknown tag; v2-capable clients check it and use the matching wire format — a client implementation should keep both wrap paths (mostro-core ships both) to @@ -151,7 +151,7 @@ with the clients that community uses. - **v0.18.0** — protocol v2 ships. Default `transport = "gift-wrap"` (nothing changes for existing clients). **Protocol v1 is DEPRECATED**: - announced in release notes, protocol docs and the `protocol_versions` + announced in release notes, protocol docs and the `protocol_version` tag. Client developers have the 0.18.x cycle to ship v2. - **v0.19.0** — protocol v2 becomes the default and only protocol. Everything v1-related is removed from mostrod (gift-wrap path, @@ -191,7 +191,7 @@ Minimal daemon integration; **zero handler changes** by design: - `src/util.rs send_dm()` — wraps via `wrap_message_with(transport, …)`; on the nip44 transport, fills a default NIP-40 expiration from `dm_days` when the caller didn't pass one. -- `src/nip33.rs` — `protocol_versions` tag in the kind-38385 info event. +- `src/nip33.rs` — `protocol_version` tag in the kind-38385 info event. ### Phase 2 — anti-spam gates (DONE — this change; daemon-only, the payoff) @@ -237,7 +237,7 @@ the gate sits entirely in the event-loop preamble. `key_management.md` (v2 examples mirroring the existing unencrypted gift-wrap walkthroughs), migration guide for client developers. - mostro-cli / client support via the same mostro-core 0.13.0 APIs: - clients keep both wrap paths and pick per node from `protocol_versions`. + clients keep both wrap paths and pick per node from `protocol_version`. ### Phase 4 — the v0.19.0 cutover (PENDING) diff --git a/src/nip33.rs b/src/nip33.rs index a73064ac..fbff4bc4 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -536,7 +536,7 @@ pub fn info_to_tags(ln_status: &LnStatus) -> Tags { // `transport` setting so clients pick the right wire format before // sending anything. See docs/TRANSPORT_V2_SPEC.md. Tag::custom( - TagKind::Custom(Cow::Borrowed("protocol_versions")), + TagKind::Custom(Cow::Borrowed("protocol_version")), vec![mostro_settings.transport.protocol_version().to_string()], ), Tag::custom( From 82f1923b0f7c55fc71cd3caa7fe09ce322eb0da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Thu, 18 Jun 2026 21:09:38 +0200 Subject: [PATCH 19/23] =?UTF-8?q?feat(price):=20Phase=203=20=E2=80=94=20El?= =?UTF-8?q?=20Toque=20fiat-cross=20provider=20(CUP/MLC)=20(#778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) 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) Claude-Session: https://claude.ai/code/session_015NvSncWrjedLiAtAV4DTJR --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/PRICE_PROVIDERS.md | 21 +- settings.tpl.toml | 14 +- src/main.rs | 27 +- src/price/manager.rs | 64 +++-- src/price/providers/eltoque.rs | 330 +++++++++++++++++++++++++ src/price/providers/mod.rs | 1 + tests/fixtures/price/eltoque_trmi.json | 14 ++ 7 files changed, 441 insertions(+), 30 deletions(-) create mode 100644 src/price/providers/eltoque.rs create mode 100644 tests/fixtures/price/eltoque_trmi.json diff --git a/docs/PRICE_PROVIDERS.md b/docs/PRICE_PROVIDERS.md index d07ece81..da7dab53 100644 --- a/docs/PRICE_PROVIDERS.md +++ b/docs/PRICE_PROVIDERS.md @@ -474,7 +474,7 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (§6.6) | 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | — | done (PR #753) | | 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | done (PR #753) | | 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) → real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | in review | -| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | pending | +| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | in review (see §11.3) | | 4 | Unify `get_market_quote` onto the cache; staleness TTL enforcement (`PriceTooStale`) at create/take | 2 | pending | | 5 | Nostr aggregated publishing + token/paid-provider support polish + info-event exposure + retire `bitcoin_price.rs` + ops docs | 3, 4 | pending | @@ -731,6 +731,25 @@ a BTC price source**. Therefore: ever switched to official, we would scope its CUP out too and lean on El Toque. +> **Phase 3 shipped status.** The El Toque adapter +> (`src/price/providers/eltoque.rs`) is wired with **anchor = USD only** +> (Q2 above declined for this phase). The request and response are confirmed +> against the live API: +> - **Request:** `GET {url}/v1/trmi?date_from=…&date_to=…` with +> `Authorization: Bearer `. The endpoint requires a +> `[date_from, date_to]` range (`YYYY-MM-DD HH:MM:SS`, URL-encoded) and +> returns the most recent rate within it, so `fetch` queries a rolling +> 48h window ending "now". +> - **Response:** a CUP-denominated `tasas` object +> (`{"tasas":{"USD":490.0,"MLC":200.0,"ECU":540.0,…}}`, where El Toque uses +> `ECU` for the euro) plus the timestamp of the returned rate +> (`date`/`hour`/`minutes`/`seconds`, which the parser ignores). The parse +> path applies the §11.3 cross math and is fully unit-tested against a +> captured fixture (`tests/fixtures/price/eltoque_trmi.json`). +> +> Q1/Q3 remain open. Keep `enabled = false` in production until a token is +> provisioned and the operator opts in. + ### 11.4 Blockchain.com (direct, 28 major fiats, NO CUP/MLC) - `GET https://blockchain.info/ticker` → `{ "USD": { "15m":76273, "last":76273, "buy":…, "sell":…, "symbol":"USD" }, … }`. diff --git a/settings.tpl.toml b/settings.tpl.toml index a8c9de5b..58399866 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -167,9 +167,17 @@ port = 50051 # enabled = true # url = "https://blockchain.info" # -# # El Toque (fiat-cross CUP/MLC, requires a token) is wired in Phase 3 — -# # see docs/PRICE_PROVIDERS.md §7 for the full provider list and §6.6 for -# # the `only` / `except` per-provider currency scoping rules. +# # El Toque — informal-market CUP/MLC (fiat-cross, resolved against the +# # aggregated USD/BTC anchor; §6.3, §11.3). Opt-in: requires a free Bearer +# # token. Scoped to CUP/MLC only — that is all this source contributes. +# # NOTE: the request wiring is PROVISIONAL pending a confirmed payload from +# # the token-gated API — keep `enabled = false` in production until then +# # (see docs/PRICE_PROVIDERS.md §11.3). +# [price.providers.eltoque] +# enabled = false +# url = "https://tasas.eltoque.com" +# # token = "xxxx" # REQUIRED when enabled; provider refuses to start otherwise +# only = ["CUP", "MLC"] # Anti-abuse bond (issue #711). Opt-in, disabled by default. Uncomment to # require a Lightning hold-invoice bond from takers and/or makers. See diff --git a/src/main.rs b/src/main.rs index 09bd8bf7..b444aaa2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,7 +235,32 @@ fn install_price_manager() -> std::result::Result<(), Box let mostro_settings = Settings::get_mostro(); let price_settings = match Settings::get_price() { - Some(p) => p.clone(), + Some(p) => { + // Multi-source mode: the `[price.providers.*]` tables drive + // aggregation, so the legacy `[mostro].bitcoin_price_api_url` is + // not consulted here. Surface that explicitly so an operator who + // still has the legacy key set isn't misled into thinking it + // takes effect — name the providers actually in play instead. + let mut enabled: Vec<&str> = p + .providers + .iter() + .filter(|(_, cfg)| cfg.enabled) + .map(|(id, _)| id.as_str()) + .collect(); + enabled.sort_unstable(); + let enabled = if enabled.is_empty() { + "".to_string() + } else { + enabled.join(", ") + }; + tracing::warn!( + "price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \ + aggregation because `[price]` is configured; using enabled providers: {}", + mostro_settings.bitcoin_price_api_url, + enabled, + ); + p.clone() + } None => synthesise_legacy_price_settings( &mostro_settings.bitcoin_price_api_url, mostro_settings.exchange_rates_update_interval_seconds, diff --git a/src/price/manager.rs b/src/price/manager.rs index 919a1586..5df28612 100644 --- a/src/price/manager.rs +++ b/src/price/manager.rs @@ -7,10 +7,10 @@ //! providers, aggregate, and write the store; consumers (`get_bitcoin_price`, //! `BitcoinPriceManager::get_price`) read through [`PriceManager::get_price`]. //! -//! ## Phase 1 / 2 invariants (spec §9) +//! ## Phase 1 / 2 / 3 invariants (spec §9) //! - The registry is built from `[price]`; the direct quoters (Yadio, -//! CoinGecko, currency-api, Blockchain) are wired, El Toque lands in -//! Phase 3. +//! CoinGecko, currency-api, Blockchain) and the El Toque fiat-cross +//! quoter (Phase 3, CUP/MLC) are all wired. //! - Staleness is **logged, not enforced**: a value older than one //! `update_interval` emits a `warn!` but still returns to the caller, so //! Phases 1–3 never refuse an order that would have priced today. @@ -40,6 +40,7 @@ use super::provider::{PriceProvider, ProviderError, ProviderHealth, ProviderId, use super::providers::blockchain::BlockchainProvider; use super::providers::coingecko::CoinGeckoProvider; use super::providers::currency_api::CurrencyApiProvider; +use super::providers::eltoque::ElToqueProvider; use super::providers::yadio::YadioProvider; use super::store::{PriceError, PriceStore}; @@ -560,13 +561,10 @@ fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Result Ok(Box::new(CoinGeckoProvider::new(cfg))), ProviderId::CurrencyApi => Ok(Box::new(CurrencyApiProvider::new(cfg))), ProviderId::Blockchain => Ok(Box::new(BlockchainProvider::new(cfg))), - // El Toque lands in Phase 3. Reject explicitly so an over-eager - // config doesn't silently spawn nothing. - ProviderId::ElToque => Err(format!( - "price: provider `{id}` is configured (enabled) but not yet implemented in \ - this release — disable it or remove it from `[price.providers]` \ - (see docs/PRICE_PROVIDERS.md §7)" - )), + // El Toque (fiat-cross CUP/MLC). `new` returns `Err` when the + // required Bearer token is missing, so an enabled-but-unconfigured + // provider fails fast at startup (spec §7). + ProviderId::ElToque => Ok(Box::new(ElToqueProvider::new(cfg)?)), } } @@ -831,23 +829,39 @@ mod tests { cfg.validate().expect("synthesised config must validate"); } + fn eltoque_cfg(token: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: "https://tasas.eltoque.com".into(), + fallback_urls: vec![], + api_key: None, + token: token.map(String::from), + only: Some(vec!["CUP".into(), "MLC".into()]), + except: None, + } + } + #[test] - fn from_settings_rejects_unimplemented_provider_id() { - // An enabled provider whose adapter isn't yet wired (El Toque, - // Phase 3) must fail at startup, not silently produce nothing. + fn from_settings_builds_eltoque_with_token() { + // Phase 3: El Toque is now wired. With its required Bearer token it + // builds into the registry like any other provider. let mut settings = PriceSettings::default(); - settings.providers.insert( - ProviderId::ElToque.to_string(), - ProviderConfig { - enabled: true, - url: "https://tasas.eltoque.com".into(), - fallback_urls: vec![], - api_key: None, - token: Some("x".into()), - only: None, - except: None, - }, - ); + settings + .providers + .insert(ProviderId::ElToque.to_string(), eltoque_cfg(Some("tok"))); + let m = PriceManager::from_settings(settings).expect("eltoque builds with a token"); + assert_eq!(m.providers.len(), 1); + assert_eq!(m.providers[0].id, ProviderId::ElToque); + } + + #[test] + fn from_settings_rejects_eltoque_without_token() { + // Spec §7: an enabled El Toque missing its required Bearer token must + // fail fast at startup, not silently produce nothing. + let mut settings = PriceSettings::default(); + settings + .providers + .insert(ProviderId::ElToque.to_string(), eltoque_cfg(None)); assert!(PriceManager::from_settings(settings).is_err()); } diff --git a/src/price/providers/eltoque.rs b/src/price/providers/eltoque.rs new file mode 100644 index 00000000..a1e87e80 --- /dev/null +++ b/src/price/providers/eltoque.rs @@ -0,0 +1,330 @@ +//! El Toque fiat-cross quoter for CUP/MLC (spec §11.3). +//! +//! El Toque publishes the **informal Cuban market rate** as *CUP per +//! foreign unit* — it is **not** a BTC price source. So unlike the direct +//! quoters, this adapter emits [`Quote::PerBase`] quotes resolved against +//! the aggregated USD/BTC anchor (spec §6.3): CUP and MLC each need at +//! least one live direct USD source (Yadio/CoinGecko/…) to resolve. +//! +//! From a `tasas` payload denominated in CUP: +//! - **CUP** → `PerBase { base: "USD", value: cup_per_usd }` (CUP per USD, +//! taken straight from `tasas.USD`). +//! - **MLC** → `PerBase { base: "USD", value: cup_per_usd / cup_per_mlc }`. +//! The cross math `MLC_per_USD = (CUP per USD) / (CUP per MLC)` is done +//! **here**, inside the adapter, so the aggregator stays generic (§11.3). +//! +//! Anchor policy: **USD only** (the spec default; the EUR second-anchor +//! fallback in §11.3 Q2 was declined for this phase). If every direct USD +//! quoter is down for a tick, CUP/MLC simply fall back to last-known-good. +//! +//! The provider is scoped to `only = ["CUP", "MLC"]` in config (§6.6); the +//! adapter independently emits only CUP/MLC, so the two agree. +//! +//! Requires a Bearer **token** (free registration). An enabled El Toque +//! provider without a token is a startup error (spec §7); the token is +//! redacted from `Debug`/logs (spec §10.3). +//! +//! ## Request +//! +//! `GET {url}/v1/trmi?date_from=…&date_to=…` with `Authorization: Bearer +//! `. The endpoint requires a `[date_from, date_to]` range (wire +//! format `YYYY-MM-DD HH:MM:SS`, URL-encoded) and returns the most recent +//! rate published within it, so [`PriceProvider::fetch`] queries a rolling +//! window ending "now" (see [`LOOKBACK_HOURS`]). The response is a `tasas` +//! object mapping currency codes to CUP-denominated values, plus the +//! timestamp of the returned rate (`date`/`hour`/`minutes`/`seconds`, which +//! the parser ignores). El Toque uses `ECU` for the euro. Example: +//! +//! ```json +//! { "tasas": { "USD": 490.0, "MLC": 200.0, "ECU": 540.0, … }, +//! "date": "2022-10-27", "hour": 7, "minutes": 59, "seconds": 30 } +//! ``` + +use std::collections::HashMap; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// How far back the `[date_from, date_to]` window reaches from "now". +/// +/// `/v1/trmi` returns the most recent informal-market rate published inside +/// the requested range, so the window only needs to be wide enough that a +/// quiet day (no fresh publication) still falls within it — El Toque's TRMI +/// updates roughly daily, so a ~day-wide window resolves a recent rate rather +/// than an empty `tasas`. +/// +/// **Hard cap:** the API rejects any range of 24h or more with `400 "El +/// intervalo de tiempo debe ser menor a 24 horas"`, so the window must stay +/// *strictly* under 24h. 23h leaves a 1h margin for request-construction skew +/// while still spanning roughly a full daily cycle. +const LOOKBACK_HOURS: i64 = 23; + +/// `date_from`/`date_to` wire format, e.g. `2022-10-27 00:00:01` +/// (sent URL-encoded by reqwest, matching the El Toque API). +const DATE_FMT: &str = "%Y-%m-%d %H:%M:%S"; + +/// Response shape: `{ "tasas": { "USD": 442.0, "MLC": 210.0, … } }`. +/// +/// Values are **CUP per unit** of the keyed currency. Lenient on the value +/// (`Option`) so one `null` rate cannot fail the whole poll; any other +/// top-level fields El Toque returns (date range, etc.) are ignored. +#[derive(Debug, Deserialize)] +struct ElToqueResponse { + tasas: HashMap>, +} + +/// Fiat-cross quoter against the El Toque tasas API. +pub struct ElToqueProvider { + url: String, + token: String, +} + +// Manual impl so the Bearer token can never leak through `{:?}` logging +// (spec §10.3 redaction requirement). +impl std::fmt::Debug for ElToqueProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ElToqueProvider") + .field("url", &self.url) + .field("token", &"") + .finish() + } +} + +impl ElToqueProvider { + /// Build the provider from its `[price.providers.eltoque]` sub-table. + /// + /// Returns `Err` when the required Bearer `token` is missing or blank so + /// an enabled-but-unconfigured El Toque fails fast at startup rather + /// than silently producing no quotes (spec §7). + pub fn new(cfg: &ProviderConfig) -> Result { + let token = cfg + .token + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + "price provider 'eltoque': enabled provider requires a `token` (Bearer API \ + key) — set it or disable the provider (see docs/PRICE_PROVIDERS.md §7)" + .to_string() + })?; + Ok(Self { + url: cfg.url.trim_end_matches('/').to_string(), + token: token.to_string(), + }) + } + + /// Parse a `tasas` payload into CUP/MLC [`Quote::PerBase`] entries. + /// + /// This is the grounded, testable core (spec §10.5): it targets the + /// confirmed CUP-denominated `tasas` shape and performs the §11.3 cross + /// math. Both outputs hang off the USD anchor: + /// + /// - CUP needs `tasas.USD` (CUP per USD). Absent → emit nothing + /// (without a CUP/USD figure nothing here is resolvable). + /// - MLC additionally needs `tasas.MLC` (CUP per MLC) to derive + /// `MLC_per_USD = cup_per_usd / cup_per_mlc`. + pub(crate) fn parse(body: &str) -> Result { + let parsed: ElToqueResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("eltoque: {e}")))?; + let tasas = parsed.tasas; + let mut out = ProviderQuotes::new(); + + // The CUP/USD figure anchors everything El Toque contributes. + let cup_per_usd = match tasas.get("USD") { + Some(Some(v)) if v.is_finite() && *v > 0.0 => *v, + _ => return Ok(out), + }; + out.insert( + "CUP".to_string(), + Quote::PerBase { + base: "USD".to_string(), + value: cup_per_usd, + }, + ); + + // MLC per USD = (CUP per USD) / (CUP per MLC) — derived internally so + // the aggregator only ever sees a clean `PerBase { base: "USD" }`. + if let Some(Some(cup_per_mlc)) = tasas.get("MLC") { + if cup_per_mlc.is_finite() && *cup_per_mlc > 0.0 { + let mlc_per_usd = cup_per_usd / cup_per_mlc; + if mlc_per_usd.is_finite() && mlc_per_usd > 0.0 { + out.insert( + "MLC".to_string(), + Quote::PerBase { + base: "USD".to_string(), + value: mlc_per_usd, + }, + ); + } + } + } + + Ok(out) + } +} + +#[async_trait] +impl PriceProvider for ElToqueProvider { + fn id(&self) -> ProviderId { + ProviderId::ElToque + } + + /// `GET {url}/v1/trmi?date_from=…&date_to=…` with Bearer-token auth. + /// + /// The endpoint requires a `[date_from, date_to]` range and returns the + /// most recent rate published within it; we query a rolling window ending + /// "now" (see [`LOOKBACK_HOURS`]) so each poll resolves the latest TRMI. + /// reqwest URL-encodes the `YYYY-MM-DD HH:MM:SS` params. + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/v1/trmi", self.url); + let now = Utc::now(); + let from = now - Duration::hours(LOOKBACK_HOURS); + let res = http + .get(&url) + .bearer_auth(&self.token) + .query(&[ + ("date_from", from.format(DATE_FMT).to_string()), + ("date_to", now.format(DATE_FMT).to_string()), + ]) + .send() + .await + .map_err(|e| ProviderError::Http(format!("eltoque GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "eltoque GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("eltoque read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A captured El Toque `/v1/trmi` response (CUP-denominated `tasas`). + const SAMPLE_PAYLOAD: &str = include_str!("../../../tests/fixtures/price/eltoque_trmi.json"); + + fn cfg(url: &str, token: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: None, + token: token.map(String::from), + only: Some(vec!["CUP".into(), "MLC".into()]), + except: None, + } + } + + /// Pull the `value` out of a `PerBase { base: "USD", .. }` quote, asserting + /// the base is USD (the only anchor this phase emits). + fn per_usd(q: &Quote) -> f64 { + match q { + Quote::PerBase { base, value } => { + assert_eq!(base, "USD", "El Toque must anchor on USD"); + *value + } + Quote::PerBtc(_) => panic!("El Toque must emit PerBase, not PerBtc"), + } + } + + #[test] + fn parses_sample_payload_into_cup_and_mlc_perbase() { + let quotes = ElToqueProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Only CUP and MLC — El Toque's other `tasas` entries (USD anchor, + // ECU=EUR, crypto) are not contributed by this fiat-cross adapter. + assert_eq!(quotes.len(), 2, "exactly CUP and MLC are emitted"); + + // CUP per USD is taken straight from `tasas.USD` (490 in the sample). + assert!((per_usd("es["CUP"]) - 490.0).abs() < 1e-9); + + // MLC per USD = cup_per_usd / cup_per_mlc = 490 / 200. + assert!((per_usd("es["MLC"]) - 490.0 / 200.0).abs() < 1e-9); + + // Resolved against a USD/BTC anchor this gives sane per-BTC figures: + // CUP/BTC = 490 × USD/BTC, MLC/BTC = (490/200) × USD/BTC — i.e. 1 MLC + // is worth 200 CUP, matching the source. Cross-check the ratio. + let cup_per_btc = per_usd("es["CUP"]) * 50_000.0; // pretend USD/BTC + let mlc_per_btc = per_usd("es["MLC"]) * 50_000.0; + assert!( + (cup_per_btc / mlc_per_btc - 200.0).abs() < 1e-6, + "1 MLC must price at 200 CUP, matching tasas" + ); + } + + #[test] + fn mlc_cross_math_is_cup_per_usd_over_cup_per_mlc() { + let body = r#"{"tasas":{"USD":400.0,"MLC":250.0,"ECU":420.0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert!((per_usd("es["CUP"]) - 400.0).abs() < 1e-9); + assert!((per_usd("es["MLC"]) - 400.0 / 250.0).abs() < 1e-9); + // ECU (El Toque's EUR) is deliberately not emitted — El Toque only + // contributes CUP/MLC (§11.3); EUR comes from the direct quoters. + assert!(!quotes.contains_key("EUR")); + assert!(!quotes.contains_key("ECU")); + } + + #[test] + fn no_usd_anchor_emits_nothing() { + // Without CUP/USD nothing El Toque reports can be resolved. + let body = r#"{"tasas":{"MLC":210.0,"ECU":500.0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert!(quotes.is_empty(), "no tasas.USD → no resolvable quotes"); + } + + #[test] + fn non_positive_rates_are_dropped() { + // USD present but MLC is junk → CUP still emitted, MLC dropped. + let body = r#"{"tasas":{"USD":442.0,"MLC":0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert!(quotes.contains_key("CUP")); + assert!(!quotes.contains_key("MLC")); + + // USD itself non-positive → nothing at all. + let body = r#"{"tasas":{"USD":0,"MLC":210.0}}"#; + assert!(ElToqueProvider::parse(body).unwrap().is_empty()); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + ElToqueProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn new_requires_a_token() { + // Spec §7: an enabled El Toque without a token fails fast. + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", None)).is_err()); + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some(" "))).is_err()); + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("tok"))).is_ok()); + } + + #[test] + fn debug_redacts_token() { + // Spec §10.3: the Bearer token must never appear in `Debug` (logs). + let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("super-secret-key"))) + .unwrap(); + let dbg = format!("{p:?}"); + assert!(!dbg.contains("super-secret-key"), "token leaked: {dbg}"); + assert!(dbg.contains("")); + } + + #[test] + fn new_strips_trailing_slash() { + let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com/", Some("tok"))).unwrap(); + assert_eq!(p.url, "https://tasas.eltoque.com"); + } +} diff --git a/src/price/providers/mod.rs b/src/price/providers/mod.rs index a86fe7c0..d1a796f7 100644 --- a/src/price/providers/mod.rs +++ b/src/price/providers/mod.rs @@ -9,4 +9,5 @@ pub mod blockchain; pub mod coingecko; pub mod currency_api; +pub mod eltoque; pub mod yadio; diff --git a/tests/fixtures/price/eltoque_trmi.json b/tests/fixtures/price/eltoque_trmi.json new file mode 100644 index 00000000..837a24aa --- /dev/null +++ b/tests/fixtures/price/eltoque_trmi.json @@ -0,0 +1,14 @@ +{ + "tasas": { + "ECU": 540.0, + "TRX": 150.0, + "MLC": 200.0, + "USD": 490.0, + "BTC": 490.0, + "USDT_TRC20": 525.0 + }, + "date": "2022-10-27", + "hour": 7, + "minutes": 59, + "seconds": 30 +} From 4a6722ad0ededd4de76f2f05263d7f9b590e7faf Mon Sep 17 00:00:00 2001 From: Andrea Diaz Date: Fri, 19 Jun 2026 10:09:22 -0300 Subject: [PATCH 20/23] fix: surface InvalidOrderId to clients as CantDo(NotFound) (#752) * fix: return CantDo(NotFound) for missing orders instead of InvalidOrderId * fix: format order_id extraction in get_order (no functional change) --- src/app/admin_take_dispute.rs | 4 +-- src/app/dispute.rs | 2 +- src/util.rs | 65 ++++++++++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/app/admin_take_dispute.rs b/src/app/admin_take_dispute.rs index 16803ce8..50e0b0f4 100644 --- a/src/app/admin_take_dispute.rs +++ b/src/app/admin_take_dispute.rs @@ -180,11 +180,11 @@ pub async fn admin_take_dispute_action( // Get order from db using the dispute order id let order = if let Some(order) = Order::by_id(pool, dispute.order_id) .await - .map_err(|_| MostroInternalErr(ServiceError::InvalidOrderId))? + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? { order } else { - return Err(MostroInternalErr(ServiceError::InvalidOrderId)); + return Err(MostroCantDo(CantDoReason::NotFound)); }; // Update dispute fields diff --git a/src/app/dispute.rs b/src/app/dispute.rs index 51c4a76d..27b62a4d 100644 --- a/src/app/dispute.rs +++ b/src/app/dispute.rs @@ -159,7 +159,7 @@ pub async fn dispute_action( let order_id = if let Some(order_id) = msg.get_inner_message_kind().id { order_id } else { - return Err(MostroInternalErr(ServiceError::InvalidOrderId)); + return Err(MostroCantDo(CantDoReason::NotFound)); }; // Check dispute for this order id is yet present. if find_dispute_by_order_id(pool, order_id).await.is_ok() { diff --git a/src/util.rs b/src/util.rs index 686e01d1..b1df2b82 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1433,16 +1433,14 @@ pub async fn get_dispute(msg: &Message, pool: &Pool) -> Result) -> Result { let order_msg = msg.get_inner_message_kind(); - let order_id = order_msg - .id - .ok_or(MostroInternalErr(ServiceError::InvalidOrderId))?; + let order_id = order_msg.id.ok_or(MostroCantDo(CantDoReason::NotFound))?; let order = Order::by_id(pool, order_id) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; if let Some(order) = order { Ok(order) } else { - Err(MostroInternalErr(ServiceError::InvalidOrderId)) + Err(MostroCantDo(CantDoReason::NotFound)) } } @@ -1907,6 +1905,65 @@ mod tests { assert!(orders.is_empty()); } + #[tokio::test] + async fn test_get_order_returns_not_found_when_id_missing() { + initialize(); + let pool = setup_orders_pool().await; + let message = Message::Order(MessageKind::new( + None, + None, + None, + Action::AdminSettle, + None, + )); + + let err = get_order(&message, &pool).await.unwrap_err(); + assert!(matches!( + err, + MostroError::MostroCantDo(CantDoReason::NotFound) + )); + } + + #[tokio::test] + async fn test_get_order_returns_not_found_when_order_absent() { + initialize(); + let pool = setup_orders_pool().await; + let missing_id = Uuid::new_v4(); + let message = Message::Order(MessageKind::new( + Some(missing_id), + None, + None, + Action::AdminSettle, + None, + )); + + let err = get_order(&message, &pool).await.unwrap_err(); + assert!(matches!( + err, + MostroError::MostroCantDo(CantDoReason::NotFound) + )); + } + + #[tokio::test] + async fn test_get_order_returns_order_when_found() { + initialize(); + let pool = setup_orders_pool().await; + let user_pubkey = "a".repeat(64); + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, Some(&user_pubkey), None, &user_pubkey).await; + + let message = Message::Order(MessageKind::new( + Some(order_id), + None, + None, + Action::AdminSettle, + None, + )); + + let order = get_order(&message, &pool).await.unwrap(); + assert_eq!(order.id, order_id); + } + #[test] fn test_get_dev_fee_basic() { // 1000 sats Mostro fee at 30% -> 300 sats From 6802c97fb567a22489c7ebe2353919c490ac185a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 19 Jun 2026 21:46:31 +0200 Subject: [PATCH 21/23] feat(transport): stamp inner protocol version to match active transport (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- src/util.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/util.rs b/src/util.rs index b1df2b82..83f5265a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -746,6 +746,25 @@ async fn prepare_new_order( Ok(new_order_db) } +/// Overwrite the inner protocol version of `message` so it matches the wire +/// `transport` (`gift-wrap` -> v1, `nip44` -> v2). +/// +/// `MessageKind::new` always stamps the crate-wide `PROTOCOL_VER`, so without +/// this every reply would advertise v2 even when it is served over the v1 +/// gift-wrap transport. Keeping the inner version aligned with the transport +/// lets the protocol version follow the negotiated wire format. +fn stamp_protocol_version(message: &mut Message, transport: Transport) { + let version = transport.protocol_version(); + match message { + Message::Order(k) + | Message::Dispute(k) + | Message::CantDo(k) + | Message::Rate(k) + | Message::Dm(k) + | Message::Restore(k) => k.version = version, + } +} + pub async fn send_dm( receiver_pubkey: PublicKey, sender_keys: &Keys, @@ -757,13 +776,18 @@ pub async fn send_dm( sender_keys.public_key().to_hex(), receiver_pubkey.to_hex() ); - let message = Message::from_json(payload) + let mut message = Message::from_json(payload) .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; // Non-panicking accessor: send_dm sits on every reply path and is // exercised by unit tests that don't initialize the global config. let transport = Settings::get_transport(); + // Stamp the inner protocol version to match the active wire transport. + // Done before wrapping so the version is covered by the message/trade + // signatures. + stamp_protocol_version(&mut message, transport); + // Kind-14 events are visible to relays, so they always carry a NIP-40 // expiration tag (default 30 days via `dm_days`) instead of lingering // forever. Callers that pass an explicit expiration keep it. @@ -1631,6 +1655,45 @@ mod tests { }); } + #[test] + fn stamp_protocol_version_follows_transport() { + use mostro_core::message::Action; + + // A v2-stamped message (the `MessageKind::new` default) must be + // downgraded to v1 when served over the gift-wrap transport... + let mut msg = Message::new_order( + Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")), + Some(1), + None, + Action::NewOrder, + None, + ); + stamp_protocol_version(&mut msg, Transport::GiftWrap); + assert_eq!(msg.get_inner_message_kind().version, 1); + + // ...and stamped back to v2 over the nip44 direct transport. + stamp_protocol_version(&mut msg, Transport::Nip44Direct); + assert_eq!(msg.get_inner_message_kind().version, 2); + } + + #[test] + fn stamp_protocol_version_covers_all_variants() { + use mostro_core::message::Action; + + let kind = MessageKind::new(None, Some(1), None, Action::CantDo, None); + for mut msg in [ + Message::Order(kind.clone()), + Message::Dispute(kind.clone()), + Message::CantDo(kind.clone()), + Message::Rate(kind.clone()), + Message::Dm(kind.clone()), + Message::Restore(kind.clone()), + ] { + stamp_protocol_version(&mut msg, Transport::GiftWrap); + assert_eq!(msg.get_inner_message_kind().version, 1); + } + } + #[test] fn select_yadio_base_url_prefers_enabled_provider() { // Enabled provider with a usable URL wins over the legacy key. From f64e93b5bf2a64cc6d591ec2ab4e2aa89e3438b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Fri, 19 Jun 2026 22:35:40 +0200 Subject: [PATCH 22/23] ci(mutation): run as scheduled audit + opt-in, not on every push to main (#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) --- .github/workflows/mutation.yml | 15 +++++++++++---- docs/MUTATION_TESTING.md | 23 ++++++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index bad8dd3d..818255f1 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,8 +1,11 @@ name: Mutation Testing on: - push: - branches: [main] + # NOTE: intentionally NOT run on push to main. Full mutation testing is + # slow (one test-suite run per mutant) and `continue-on-error` here, so it + # gates nothing — running it on every merge only burns CI minutes. It runs + # as a weekly audit (schedule) and on demand (workflow_dispatch), plus an + # opt-in per-PR job (the `run-mutation` label). pull_request: branches: [main] types: [opened, synchronize, reopened, labeled] @@ -38,7 +41,9 @@ jobs: cache-on-failure: true - name: Install cargo-mutants - run: cargo install cargo-mutants + uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants - name: Run mutation testing on changed files # Only test mutants in files changed in this PR @@ -185,7 +190,9 @@ jobs: cache-on-failure: true - name: Install cargo-mutants - run: cargo install cargo-mutants + uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants - name: Run full mutation testing run: cargo mutants diff --git a/docs/MUTATION_TESTING.md b/docs/MUTATION_TESTING.md index 2fa9bb29..e508f106 100644 --- a/docs/MUTATION_TESTING.md +++ b/docs/MUTATION_TESTING.md @@ -110,11 +110,21 @@ test_tool_options = ["--", "--test-threads=1"] ## CI/CD Integration -Mutation testing runs in CI on every PR and on main branch: - -1. **PR workflow**: Runs mutation testing on changed files only (faster feedback) -2. **Main branch**: Runs full mutation testing weekly (baseline tracking) -3. **Release gate**: Mutation score must not decrease from previous release +Mutation testing is **not** run on every push to `main`: a full run is slow +(one test-suite run per mutant) and the job is `continue-on-error`, so running +it on every merge would only burn CI minutes without gating anything. Instead +it runs as a periodic audit plus an opt-in per-PR job: + +1. **PR workflow (opt-in)**: Add the `run-mutation` label to a PR to test + mutants in the files it changed only (faster feedback). +2. **Weekly baseline** (`schedule`): Full mutation testing every Sunday for + baseline tracking, with the HTML report published to GitHub Pages. +3. **On demand** (`workflow_dispatch`): Trigger a full run manually from the + Actions tab whenever needed. + +For pre-push feedback, run it locally instead (see [Running Locally](#running-locally)) +— `cargo mutants --in-diff` against `main` is the closest equivalent to the +opt-in PR job. ### Initial Setup (Non-blocking) @@ -224,6 +234,9 @@ fn test_order_validation_rejects_invalid() { # Or run cargo-mutants directly: cargo mutants --file src/flow.rs --output mutants.out + +# Pre-push: only mutants in your diff vs main (mirrors the opt-in PR job) +cargo mutants --in-diff <(git diff origin/main...HEAD) ``` ## Performance Considerations From 251485681b8768d0bcae5d53019075701a56dcd7 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 24 Jun 2026 15:31:01 -0300 Subject: [PATCH 23/23] fix(cashu): harden release happy path and fix CI blockers 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. --- docker-compose.cashu.yml | 3 + sqlx-data.json | 2 +- src/app/release.rs | 189 ++++++++++++++++++++++++--------------- src/cashu/mod.rs | 145 ++++++++++++++++++------------ src/db.rs | 3 +- src/escrow.rs | 21 +++-- 6 files changed, 229 insertions(+), 134 deletions(-) diff --git a/docker-compose.cashu.yml b/docker-compose.cashu.yml index 7d6789f6..14b4b4a1 100644 --- a/docker-compose.cashu.yml +++ b/docker-compose.cashu.yml @@ -17,6 +17,9 @@ services: MINT_BACKEND_BOLT11_SAT: FakeWallet MINT_LISTEN_HOST: 0.0.0.0 MINT_LISTEN_PORT: "3338" + # Non-sensitive fixture: this is a throwaway FakeWallet test mint used + # only by the local/CI Cashu integration tests. It holds no real funds and + # must never be reused for a production mint. MINT_PRIVATE_KEY: TEST_PRIVATE_KEY healthcheck: test: diff --git a/sqlx-data.json b/sqlx-data.json index 00e1a01d..5ff35e0d 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -60,4 +60,4 @@ }, "query": "\n UPDATE orders\n SET\n cashu_mint_url = ?1,\n cashu_escrow_token = ?2,\n cashu_escrow_locked_at = ?3,\n status = ?4\n WHERE id = ?5 AND status = ?6 AND cashu_escrow_locked_at IS NULL\n " } -} \ No newline at end of file +} diff --git a/src/app/release.rs b/src/app/release.rs index b6ae4839..24d52d49 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -193,22 +193,78 @@ pub async fn release_action( let is_cashu = Settings::is_cashu_enabled() && order.cashu_escrow_token.is_some(); if is_cashu { - // Cashu flow: skip lightning invoice settlement and go straight to Success. - // Update order event with status Success + // Cashu flow: there is no Lightning hold invoice to settle. Mostro only + // coordinates the 2-of-3 escrow, so "release" means handing the buyer + // the P_M (Mostro) signatures they need to redeem the escrowed ecash and + // advancing the order to Success. + // + // Compute the signatures *before* mutating any state. If anything here + // fails we return early and leave the order in FiatSent/Dispute so the + // seller can retry — rather than marking the order Success and notifying + // the buyer of a completed purchase they cannot actually claim. + let token_str = order.cashu_escrow_token.as_ref().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "cashu_escrow_token missing on order in Cashu mode".to_string(), + )) + })?; + + let token = cdk::nuts::Token::from_str(token_str).map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to parse Cashu escrow token: {e}" + ))) + })?; + + // The node's Nostr secret key doubles as the Cashu P_M signing key. + let p_m_secret = cdk::nuts::nut01::SecretKey::from_str( + &my_keys.secret_key().to_secret_hex(), + ) + .map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to derive Cashu P_M signing key from node key: {e}" + ))) + })?; + + let mut pm_signatures = Vec::new(); + for secret in token.token_secrets() { + let msg = secret.to_bytes(); + let sig = p_m_secret.sign(&msg).map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to sign Cashu proof secret for order {}: {e}", + order.id + ))) + })?; + pm_signatures.push(mostro_core::message::CashuProofSignature::new( + secret.to_string(), + sig.to_string(), + )); + } + + // A token with no proofs to sign means the buyer would receive a + // PurchaseCompleted with no way to redeem the escrow — treat it as a + // hard error so the order stays retryable instead of silently stuck. + if pm_signatures.is_empty() { + return Err(MostroInternalErr(ServiceError::UnexpectedError(format!( + "Cashu escrow token for order {} contained no proof secrets to sign", + order.id + )))); + } + + // Signatures are ready — now it is safe to commit the Success transition. order = update_order_event(my_keys, Status::Success, &order) .await .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; - let result = - sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") - .bind(&order.status) - .bind(&order.event_id) - .bind(order.id) - .bind(Status::FiatSent.to_string()) - .bind(Status::Dispute.to_string()) - .execute(pool) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + let result = sqlx::query( + "UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)", + ) + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; if result.rows_affected() == 0 { tracing::warn!( @@ -218,7 +274,9 @@ pub async fn release_action( return Ok(()); } - // Send PurchaseCompleted message to buyer + // Notify the buyer the purchase completed, then deliver the P_M + // signatures "just in case" the seller forgot to DM their own signature + // to the buyer via NIP-59. enqueue_order_msg( None, Some(order.id), @@ -229,35 +287,15 @@ pub async fn release_action( ) .await; - // Generate and send PM signatures to the buyer "just in case" the seller forgot - // to send their own signature to the buyer via NIP-59 DM. - 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; - } + enqueue_order_msg( + request_id, + Some(order.id), + Action::CashuPmSignature, + Some(Payload::CashuSignatures(pm_signatures)), + buyer_pubkey, + None, + ) + .await; // Send dm to buyer to rate counterpart enqueue_order_msg( @@ -283,16 +321,17 @@ pub async fn release_action( // explicit write the settled-hold-invoice status only lived in memory and // was persisted as a side-effect of the full-row writes in // check_failure_retries / payment_success (now replaced by targeted updates). - let result = - sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") - .bind(&order.status) - .bind(&order.event_id) - .bind(order.id) - .bind(Status::FiatSent.to_string()) - .bind(Status::Dispute.to_string()) - .execute(pool) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + let result = sqlx::query( + "UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)", + ) + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; if result.rows_affected() == 0 { tracing::warn!( @@ -308,15 +347,20 @@ pub async fn release_action( close_dispute_after_user_resolution(ctx, &order, DisputeStatus::Settled, my_keys, "release") .await; - enqueue_order_msg( - None, - Some(order.id), - Action::Released, - None, - buyer_pubkey, - None, - ) - .await; + // In the Cashu flow the buyer was already told the purchase completed + // (PurchaseCompleted) and given the redeem signatures; the Lightning-only + // "Released" notification would be a redundant/conflicting message. + if !is_cashu { + enqueue_order_msg( + None, + Some(order.id), + Action::Released, + None, + buyer_pubkey, + None, + ) + .await; + } // Handle child order for range orders if let Ok((Some(child_order), Some(event))) = get_child_order(ctx, order.clone(), my_keys).await @@ -331,16 +375,19 @@ pub async fn release_action( } // 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; + // indicate *funds released* message to seller. This is Lightning-specific + // (there is no hold invoice in the Cashu flow), so skip it for Cashu. + if !is_cashu { + enqueue_order_msg( + request_id, + Some(order.id), + Action::HoldInvoicePaymentSettled, + None, + seller_pubkey, + None, + ) + .await; + } // We send a message to seller indicating seller released funds enqueue_order_msg( diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 4d1e42dc..8cd158d3 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -1,11 +1,10 @@ use cdk::error::Error as CdkClientError; use cdk::mint_url::MintUrl; +use cdk::nuts::{nut00::Proofs, nut01::SecretKey as NutSecretKey, nut10::SpendingConditions}; +use cdk::nuts::{nut02::ShortKeysetId, CheckStateRequest, CheckStateResponse, PublicKey, Token}; use cdk::wallet::MintConnector; -use cdk::nuts::{nut01::SecretKey as NutSecretKey, nut00::Proofs, nut10::SpendingConditions}; -use cdk::nuts::{CheckStateRequest, CheckStateResponse, PublicKey, Token, nut02::ShortKeysetId}; use std::str::FromStr; -use std::sync::OnceLock; /// Error type for Cashu client operations #[derive(Debug)] pub enum Error { @@ -28,6 +27,15 @@ impl std::fmt::Display for Error { } } +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Client(e) => Some(e), + _ => None, + } + } +} + impl From for Error { fn from(e: CdkClientError) -> Self { Error::Client(e) @@ -37,39 +45,33 @@ impl From for Error { /// A client for communicating with a Cashu mint. #[derive(Clone)] pub struct CashuClient { - mint_url: MintUrl, client: cdk::HttpClient, } -pub static CASHU_STATUS: OnceLock = OnceLock::new(); - impl CashuClient { - /// Connects to a mint URL and verifies it is reachable. + /// Connects to a mint URL and verifies it is reachable and that it + /// supports the NUT-11 P2PK spending conditions the 2-of-3 escrow needs. + /// A connection failure is surfaced to the caller via `Err` rather than a + /// process-global flag, so the daemon can decide how to react at startup. pub async fn connect(mint_url: &str) -> Result { - let url = MintUrl::from_str(mint_url) - .map_err(|e| Error::InvalidMintUrl(e.to_string()))?; - - let client = cdk::HttpClient::new(url.clone(), None); - let cashu_client = Self { - mint_url: url.clone(), - client, - }; - - match cashu_client.client.get_mint_info().await { - Ok(info) => { - if !info.nuts.nut11.supported { - CASHU_STATUS.get_or_init(|| false); - return Err(Error::MintConnection("Mint does not support NUT-11 P2PK".into())); - } - CASHU_STATUS.get_or_init(|| true); - Ok(cashu_client) - } - Err(e) => { - CASHU_STATUS.get_or_init(|| false); - let err: cdk::error::Error = e.into(); - Err(Error::MintConnection(err.to_string())) - } + let url = MintUrl::from_str(mint_url).map_err(|e| Error::InvalidMintUrl(e.to_string()))?; + + let client = cdk::HttpClient::new(url, None); + let cashu_client = Self { client }; + + let info = cashu_client + .client + .get_mint_info() + .await + .map_err(|e| Error::MintConnection(e.to_string()))?; + + if !info.nuts.nut11.supported { + return Err(Error::MintConnection( + "Mint does not support NUT-11 P2PK".into(), + )); } + + Ok(cashu_client) } /// Verifies the 2-of-3 condition embedded in a token matches the expected pubkeys. @@ -80,8 +82,7 @@ impl CashuClient { p_s: PublicKey, p_m: PublicKey, ) -> Result { - let token = Token::from_str(token) - .map_err(|e| Error::Token(e.to_string()))?; + let token = Token::from_str(token).map_err(|e| Error::Token(e.to_string()))?; let secrets = token.token_secrets(); if secrets.is_empty() { @@ -89,23 +90,36 @@ impl CashuClient { } for secret in secrets { - let spending_conditions = SpendingConditions::try_from(secret).map_err(|e| Error::Condition(e.to_string()))?; - + let spending_conditions = SpendingConditions::try_from(secret) + .map_err(|e| Error::Condition(e.to_string()))?; + if spending_conditions.num_sigs() != Some(2) { - return Err(Error::Condition("Spending condition must require exactly 2 signatures".into())); + return Err(Error::Condition( + "Spending condition must require exactly 2 signatures".into(), + )); } if spending_conditions.locktime().is_some() { - return Err(Error::Condition("Spending condition cannot have a locktime".into())); + return Err(Error::Condition( + "Spending condition cannot have a locktime".into(), + )); } if spending_conditions.refund_keys().is_some() { - return Err(Error::Condition("Spending condition cannot have refund keys".into())); + return Err(Error::Condition( + "Spending condition cannot have refund keys".into(), + )); } let pubkeys = spending_conditions.pubkeys().unwrap_or_default(); - if pubkeys.len() != 3 || !pubkeys.contains(&p_b) || !pubkeys.contains(&p_s) || !pubkeys.contains(&p_m) { - return Err(Error::Condition("Missing expected pubkeys in spending condition".into())); + if pubkeys.len() != 3 + || !pubkeys.contains(&p_b) + || !pubkeys.contains(&p_s) + || !pubkeys.contains(&p_m) + { + return Err(Error::Condition( + "Missing expected pubkeys in spending condition".into(), + )); } } @@ -117,39 +131,56 @@ impl CashuClient { /// that the proofs were signed by the mint. Use `verify_token_dleq` for that. pub async fn check_state(&self, ys: Vec) -> Result { let request = CheckStateRequest { ys }; - let response = self.client.post_check_state(request).await - .map_err(|e| { - Error::Client(cdk::error::Error::from(e)) - })?; + let response = self + .client + .post_check_state(request) + .await + .map_err(Error::Client)?; Ok(response) } /// Verifies the DLEQ proofs for all proofs in a token. /// This authenticates that the token was actually issued by the mint. pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { - let keysets = self.client.get_mint_keys().await.map_err(|e| Error::Client(cdk::error::Error::from(e)))?; - + let keysets = self.client.get_mint_keys().await.map_err(Error::Client)?; + match token { Token::TokenV3(token_v3) => { - let proofs = token_v3.token.iter().flat_map(|t| t.proofs.clone()).collect::>(); + let proofs = token_v3 + .token + .iter() + .flat_map(|t| t.proofs.clone()) + .collect::>(); for proof in proofs { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - let mint_pubkey = keyset.keys.get(&proof.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; - + let mint_pubkey = keyset + .keys + .get(&proof.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let p = proof.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } - }, + } Token::TokenV4(token_v4) => { for token_entry in &token_v4.token { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - + for proof_v4 in &token_entry.proofs { - let mint_pubkey = keyset.keys.get(&proof_v4.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let mint_pubkey = keyset + .keys + .get(&proof_v4.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; let p = proof_v4.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } } } @@ -161,7 +192,9 @@ impl CashuClient { /// Signs proofs using the arbitrator's (Mostro) secret key. pub fn sign_with_pm(proofs: &mut Proofs, p_m_secret: NutSecretKey) -> Result<(), Error> { for proof in proofs.iter_mut() { - proof.sign_p2pk(p_m_secret.clone()).map_err(|e| Error::Client(cdk::error::Error::from(e)))?; + proof + .sign_p2pk(p_m_secret.clone()) + .map_err(|e| Error::Client(cdk::error::Error::from(e)))?; } Ok(()) } diff --git a/src/db.rs b/src/db.rs index 07e9e6d2..8cd17519 100644 --- a/src/db.rs +++ b/src/db.rs @@ -804,9 +804,10 @@ pub async fn find_locked_cashu_orders(pool: &SqlitePool) -> Result, M SELECT * FROM orders WHERE cashu_escrow_locked_at IS NOT NULL - AND status = 'active' + AND status = ? "#, ) + .bind(Status::Active.to_string()) .fetch_all(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; diff --git a/src/escrow.rs b/src/escrow.rs index 453ed52e..c4c8a231 100644 --- a/src/escrow.rs +++ b/src/escrow.rs @@ -104,11 +104,22 @@ impl EscrowBackend for LndConnector { /// A placeholder for the opt-in Cashu mode. In Cashu mode Mostro is only a /// coordinator and never takes custody, so these hold-invoice primitives do not /// map onto Cashu directly — the feature tracks replace the escrow paths that -/// call them. Until then every method is `unimplemented!()` and the backend is -/// never instantiated (the daemon defaults to Lightning). +/// call them. Until then every method returns a typed error (never `panic!`) +/// and the backend is never instantiated (the daemon defaults to Lightning). +/// Returning `Err` rather than `unimplemented!()` keeps an accidental future +/// instantiation from crashing the daemon in the middle of a trade. #[derive(Debug, Default, Clone, Copy)] pub struct CashuBackend; +impl CashuBackend { + /// Error returned by every not-yet-implemented Cashu escrow primitive. + fn not_implemented(primitive: &str) -> MostroError { + MostroError::MostroInternalErr(ServiceError::UnexpectedError(format!( + "Cashu escrow {primitive} is not implemented yet" + ))) + } +} + #[async_trait] impl EscrowBackend for CashuBackend { async fn create_hold_invoice( @@ -116,14 +127,14 @@ impl EscrowBackend for CashuBackend { _description: &str, _amount: i64, ) -> Result { - unimplemented!("Cashu escrow lock is implemented in the Cashu lock track") + Err(Self::not_implemented("lock")) } async fn settle_hold_invoice(&mut self, _preimage: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow release is implemented in the Cashu release track") + Err(Self::not_implemented("release")) } async fn cancel_hold_invoice(&mut self, _hash: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow cancel is implemented in the Cashu cancel track") + Err(Self::not_implemented("cancel")) } }