Skip to content

feat(cashu): Track B - release happy path#763

Draft
a1denvalu3 wants to merge 24 commits into
MostroP2P:feat/cashu-mostro-core-0.12from
a1denvalu3:feature/cashu-track-b
Draft

feat(cashu): Track B - release happy path#763
a1denvalu3 wants to merge 24 commits into
MostroP2P:feat/cashu-mostro-core-0.12from
a1denvalu3:feature/cashu-track-b

Conversation

@a1denvalu3

@a1denvalu3 a1denvalu3 commented May 30, 2026

Copy link
Copy Markdown
Contributor

This PR implements Track B of the Cashu 2-of-3 escrow architecture described in docs/CASHU_ESCROW_ARCHITECTURE.md.

In Cashu mode, the Mostro daemon does not hold custody of funds. Therefore, in the happy path release flow, Mostro simply needs to transition the order state to Success and bypass the Lightning hold invoice settlement.

The cryptographic signatures for the release are sent out-of-band directly from the seller to the buyer via Nostr DM.

  • Bypasses Lightning invoice settlement in Cashu mode.
  • Immediately transitions the order to Success instead of SettledHoldInvoice.
  • Sends the PurchaseCompleted message to the buyer immediately.
  • Leaves standard hold invoice logic unchanged when running in Lightning mode.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added Cashu escrow payment settlement support for handling alternative payment flows
    • Orders using Cashu payments now transition to Success status and automatically send buyer notifications for purchase completion and ratings

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

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • develop

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d5bed85-fc4c-4552-80b3-feef20fe49fd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

release_action now detects Cashu enablement and order.cashu_escrow_token to branch between two settlement paths. The Cashu path skips Lightning operations, transitions the order to Success, and enqueues buyer notifications. The Lightning path settles the hold invoice and transitions to SettledHoldInvoice. The function parameter ln_client is widened to the EscrowBackend trait, and the final buyer payment is gated to Lightning path only.

Changes

Cashu Escrow Release Path

Layer / File(s) Summary
Release Action with Cashu and Lightning Paths
src/app/release.rs
release_action branches on Cashu enablement and cashu_escrow_token presence: Cashu path transitions to Success and enqueues notifications; Lightning path settles hold invoice and transitions to SettledHoldInvoice; do_payment guarded to run only when Cashu flow is not taken. Function parameter ln_client widened from &mut LndConnector to &mut dyn EscrowBackend.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • MostroP2P/mostro-cli#175: The release_action changes directly implement runtime handling for Cashu escrow fields and notifications introduced in the CLI issue.

Possibly related PRs

  • MostroP2P/mostro#760: Both PRs modify release_action to use ln_client: &mut dyn EscrowBackend parameter abstraction.
  • MostroP2P/mostro#758: The Cashu branch depends on Settings::is_cashu_enabled() and escrow-related configuration introduced in that PR.
  • MostroP2P/mostro#761: Both PRs coordinate on guarded order status transitions and Cashu escrow field stamping via compare-and-set DB operations.

Suggested reviewers

  • grunch

Poem

🐰 A tunnel splits in amber light,
One path glows Cashu-gold and bright,
The other crackles Lightning's spark—
Both lead the order from the dark
To success or settled rest,
Each chosen when it serves users best! ⚡✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The PR title clearly and specifically refers to the main change: implementing the Cashu release happy path (Track B) in the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@a1denvalu3 a1denvalu3 changed the base branch from main to feat/cashu-mostro-core-0.12 May 30, 2026 14:40
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.
@grunch

grunch commented May 31, 2026

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
src/app/release.rs (1)

333-343: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

HoldInvoicePaymentSettled is sent to seller even in Cashu mode where no hold invoice exists.

This message block runs after the if is_cashu { ... } else { ... } branches, meaning the seller receives Action::HoldInvoicePaymentSettled for Cashu orders despite there being no Lightning hold invoice. This is semantically incorrect and may confuse clients.

Consider guarding this with !is_cashu or introducing a Cashu-specific action.

Proposed fix
-    // We send a HoldInvoicePaymentSettled message to seller, the client should
-    // indicate *funds released* message to seller
-    enqueue_order_msg(
-        request_id,
-        Some(order.id),
-        Action::HoldInvoicePaymentSettled,
-        None,
-        seller_pubkey,
-        None,
-    )
-    .await;
+    // We send a HoldInvoicePaymentSettled message to seller, the client should
+    // indicate *funds released* message to seller
+    if !is_cashu {
+        enqueue_order_msg(
+            request_id,
+            Some(order.id),
+            Action::HoldInvoicePaymentSettled,
+            None,
+            seller_pubkey,
+            None,
+        )
+        .await;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/release.rs` around lines 333 - 343, The code enqueues
Action::HoldInvoicePaymentSettled unconditionally even for Cashu orders where no
hold invoice exists; update the call sites around enqueue_order_msg in
release.rs so that Action::HoldInvoicePaymentSettled is only enqueued when
!is_cashu (or replace with a Cashu-specific action), i.e., guard the
enqueue_order_msg(...) call with a check on is_cashu (or branch to a Cashu
action) to avoid sending HoldInvoicePaymentSettled for Cashu orders.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/release.rs`:
- Around line 234-260: The token parsing / key conversion / signing currently
swallows errors; update the block that builds pm_signatures so each fallible
operation logs failures (include the offending token_str/order.id and request_id
for context): log errors from
cdk::nuts::Token::from_str(order.cashu_escrow_token), from
cdk::nuts::nut01::SecretKey::from_str(my_keys.secret_key().to_secret_hex()), and
from p_m_secret.sign(&msg) before continuing, and still push any successful
CashuProofSignature::new entries into pm_signatures; use the existing logging
framework (e.g., tracing::error! or the module's logger) and ensure logs mention
functions/symbols like Token::from_str, SecretKey::from_str, p_m_secret.sign,
and CashuProofSignature so failures are diagnosable.

---

Outside diff comments:
In `@src/app/release.rs`:
- Around line 333-343: The code enqueues Action::HoldInvoicePaymentSettled
unconditionally even for Cashu orders where no hold invoice exists; update the
call sites around enqueue_order_msg in release.rs so that
Action::HoldInvoicePaymentSettled is only enqueued when !is_cashu (or replace
with a Cashu-specific action), i.e., guard the enqueue_order_msg(...) call with
a check on is_cashu (or branch to a Cashu action) to avoid sending
HoldInvoicePaymentSettled for Cashu orders.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2c8878b4-584f-49ab-9cd3-a076d34bdebc

📥 Commits

Reviewing files that changed from the base of the PR and between c445946 and 924f1b6.

📒 Files selected for processing (1)
  • src/app/release.rs

Comment thread src/app/release.rs Outdated
Comment on lines +234 to +260
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Log errors during Cashu signature generation to aid debugging.

The nested if let Ok(...) chains silently discard errors from token parsing, key conversion, and signing. If any step fails, the buyer may receive no CashuPmSignature (or partial signatures), and there's no diagnostic trail. Since P_M signatures are critical for the buyer to redeem the Cashu escrow, failures here should be logged.

Proposed fix with error logging
         let token_str = order.cashu_escrow_token.as_ref().unwrap();
-        if let Ok(token) = cdk::nuts::Token::from_str(token_str) {
-            let secrets = token.token_secrets();
-            if let Ok(p_m_secret) = cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
-                for secret in secrets {
-                    let msg = secret.to_bytes();
-                    if let Ok(sig) = p_m_secret.sign(&msg) {
-                        pm_signatures.push(mostro_core::message::CashuProofSignature::new(
-                            secret.to_string(),
-                            sig.to_string(),
-                        ));
+        match cdk::nuts::Token::from_str(token_str) {
+            Ok(token) => {
+                let secrets = token.token_secrets();
+                match cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
+                    Ok(p_m_secret) => {
+                        for secret in secrets {
+                            let msg = secret.to_bytes();
+                            match p_m_secret.sign(&msg) {
+                                Ok(sig) => {
+                                    pm_signatures.push(mostro_core::message::CashuProofSignature::new(
+                                        secret.to_string(),
+                                        sig.to_string(),
+                                    ));
+                                }
+                                Err(e) => {
+                                    tracing::warn!("Order {}: failed to sign Cashu secret: {}", order.id, e);
+                                }
+                            }
+                        }
                     }
+                    Err(e) => {
+                        tracing::warn!("Order {}: failed to parse P_M secret key: {}", order.id, e);
+                    }
                 }
             }
+            Err(e) => {
+                tracing::warn!("Order {}: failed to parse Cashu escrow token: {}", order.id, e);
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut pm_signatures = Vec::new();
let token_str = order.cashu_escrow_token.as_ref().unwrap();
if let Ok(token) = cdk::nuts::Token::from_str(token_str) {
let secrets = token.token_secrets();
if let Ok(p_m_secret) = cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
for secret in secrets {
let msg = secret.to_bytes();
if let Ok(sig) = p_m_secret.sign(&msg) {
pm_signatures.push(mostro_core::message::CashuProofSignature::new(
secret.to_string(),
sig.to_string(),
));
}
}
}
}
if !pm_signatures.is_empty() {
enqueue_order_msg(
request_id,
Some(order.id),
Action::CashuPmSignature,
Some(Payload::CashuSignatures(pm_signatures)),
buyer_pubkey,
None,
).await;
}
let mut pm_signatures = Vec::new();
let token_str = order.cashu_escrow_token.as_ref().unwrap();
match cdk::nuts::Token::from_str(token_str) {
Ok(token) => {
let secrets = token.token_secrets();
match cdk::nuts::nut01::SecretKey::from_str(&my_keys.secret_key().to_secret_hex()) {
Ok(p_m_secret) => {
for secret in secrets {
let msg = secret.to_bytes();
match p_m_secret.sign(&msg) {
Ok(sig) => {
pm_signatures.push(mostro_core::message::CashuProofSignature::new(
secret.to_string(),
sig.to_string(),
));
}
Err(e) => {
tracing::warn!("Order {}: failed to sign Cashu secret: {}", order.id, e);
}
}
}
}
Err(e) => {
tracing::warn!("Order {}: failed to parse P_M secret key: {}", order.id, e);
}
}
}
Err(e) => {
tracing::warn!("Order {}: failed to parse Cashu escrow token: {}", order.id, e);
}
}
if !pm_signatures.is_empty() {
enqueue_order_msg(
request_id,
Some(order.id),
Action::CashuPmSignature,
Some(Payload::CashuSignatures(pm_signatures)),
buyer_pubkey,
None,
).await;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/release.rs` around lines 234 - 260, The token parsing / key
conversion / signing currently swallows errors; update the block that builds
pm_signatures so each fallible operation logs failures (include the offending
token_str/order.id and request_id for context): log errors from
cdk::nuts::Token::from_str(order.cashu_escrow_token), from
cdk::nuts::nut01::SecretKey::from_str(my_keys.secret_key().to_secret_hex()), and
from p_m_secret.sign(&msg) before continuing, and still push any successful
CashuProofSignature::new entries into pm_signatures; use the existing logging
framework (e.g., tracing::error! or the module's logger) and ensure logs mention
functions/symbols like Token::from_str, SecretKey::from_str, p_m_secret.sign,
and CashuProofSignature so failures are diagnosable.

@mostronatorcoder mostronatorcoder Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the current head of this PR and I am requesting changes.

There are two blocking issues.

  1. The Cashu mode event loop is explicitly not wiring the trading actions yet.

run_cashu() rejects the core trade actions (NewOrder, TakeSell, TakeBuy, AddInvoice, Release, Cancel, AdminCancel, AdminSettle) with CantDoReason::InvalidAction instead of actually driving the escrow flow.

That means this PR does not just introduce an incomplete backend seam, it starts a Cashu-mode daemon that cannot process the core trading flow at all. That is too incomplete to merge behind an operational mode switch.

  1. The code still presents the same mixed-mode problem around the "LND not required" claim.

Examples in this head:

  • src/main.rs logs Starting in Cashu escrow mode (LND not required)
  • but important Lightning-specific paths still exist directly in src/util.rs (show_hold_invoice, invoice_subscribe) and src/app/bond/flow.rs

So the mode split is still not consistently enforced across the runtime surface.

I also re-checked the Cashu helper code and this branch still carries the same src/cashu/mod.rs clippy issues that were already visible in the sibling work (dead code / useless same-type conversions), which is another sign the branch is not merge-ready.

…t failure (MostroP2P#755)

* feat(bond): Phase 4.5 — re-prompt winner for payout invoice on payment failure

When a slashed bond's counterparty payout exhausted `payout_max_retries`
against a submitted invoice, the bond went straight to `Failed` and the
winner was never asked for a new invoice: the scheduler only enumerates
`PendingPayout` rows, and the retry loop hammered the *same* unroutable
bolt11. The §8.2 "Failed resurrection" recovery existed but only fired
if the client spontaneously resubmitted — Mostro never prompted — so in
practice the counterparty share stranded and needed operator
intervention (issue MostroP2P#750).

Phase 4.5 changes the retry-exhaustion transition in
`on_send_payment_failure`:

- **Inside the claim window**: discard the unroutable invoice and re-arm
  the invoice-request sub-phase (`payout_invoice`,
  `payout_routing_fee_sats`, `payout_payment_hash`,
  `last_invoice_request_at` cleared; `payout_attempts` reset to 0; state
  stays `PendingPayout`). The next scheduler tick re-prompts the winner
  via `request_payout_invoice`. `slashed_at` is never touched, so the
  forfeit deadline does not move and the re-prompt/retry cycle is bounded
  by `payout_claim_window_days` (→ `Forfeited`).
- **Past the claim window**: transition to `Failed` as before (terminal
  technical failure, operator review).

Daemon-only: reuses `Action::AddBondInvoice` (Phase 3) and
`Action::BondInvoiceAccepted` (Phase 3.5). No mostro-core bump, no
migration. `claim_window_seconds` is threaded through `pay_counterparty`
into `on_send_payment_failure` for unit-testability.

Spec §9.5 added; phase overview, §8.1/§8.2 cross-refs, and §14.2/§14.3
release status updated to reflect Phases 0–4 merged on main.

Closes MostroP2P#750

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): guard re-prompt against double payout on indeterminate failures

Codex review on MostroP2P#755 flagged a P1: the Phase 4.5 re-arm path cleared
`payout_payment_hash` on every exhausted retry, including non-terminal
failures (status-stream timeout, stream EOF, send_payment RPC error)
where the original payment may still be InFlight in LND. Clearing the
hash disables the reconciliation branch in `pay_counterparty`, so a
freshly-prompted invoice (different hash) could be paid while the
original payment later settles — a double payout.

Introduce `PaymentFailureKind { Terminal, Indeterminate }` and thread it
into `on_send_payment_failure`:

- Terminal (LND-confirmed Failed, or structurally unusable invoice): no
  payment is/will be in flight, so the invoice may be abandoned —
  re-arm in-window, or Failed out-of-window (unchanged Phase 4.5
  behaviour).
- Indeterminate (timeout / EOF / send RPC error): keep `payout_invoice`
  and `payout_payment_hash` so reconciliation polls LND to a definitive
  Succeeded/Failed before anything new is paid. Never re-arms, never
  flips to Failed. `payout_attempts` saturates at `payout_max_retries`
  so a long LND outage can't grow it unbounded.

The send-status stream now tracks whether it ended on an explicit
`PaymentStatus::Failed` (terminal) vs timeout/EOF (indeterminate); the
reconciliation-Failed and decode-failure paths are terminal; the
send_payment RPC error is indeterminate.

Adds `send_payment_indeterminate_failure_keeps_invoice` test; updates the
existing exhaustion tests to pass the failure kind. Spec §9.5 documents
the double-payout guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@a1denvalu3 a1denvalu3 changed the title feat(cashu): implement Track B - release happy path feat(cashu): Track B - release happy path Jun 8, 2026
grunch and others added 20 commits June 9, 2026 12:45
…2P#767)

* feat(bond): Phase 5 — maker bond (non-range) + dispute slash

Implements Phase 5 of the anti-abuse bond (docs/ANTI_ABUSE_BOND.md §10):
the maker posts a bond before the order is published, gated by
`enabled && apply_to ∈ { make, both }`.

Lifecycle (maker-specific):
- `publish_order` parks a non-range order at `Status::WaitingMakerBond`
  with NO NIP-33 event, requests the maker bond, and defers the
  publication. `finalize_order_publication` is factored out and shared
  by the no-bond inline path and the deferred resume path.
- `request_maker_bond` mints the hold invoice, persists a singleton
  `Bond` row, arms the subscriber, and ships the bolt11 as
  `Action::PayBondInvoice`. Notional is the fixed sats `amount`, or the
  price-converted fiat amount for a market-priced single order (one-time
  snapshot, not repriced — §10.3). Range makers are deferred to Phase 6.
- `on_maker_bond_accepted` does a plain `Requested → Locked` CAS (no
  first-to-lock race — the maker bond is a singleton), idempotent across
  LND redelivery and the restart resubscriber, then resumes the deferred
  publication via `resume_publish_after_maker_bond` only while the order
  is still `WaitingMakerBond`.

Slash + release hooks (reused unchanged from Phase 2/4):
- Dispute slash resolves to the maker bond by pubkey (sell-order →
  `slash_seller` targets maker; buy-order → `slash_buyer`), orthogonally
  to settle/cancel.
- Release on every existing exit is role-agnostic, so the maker bond is
  released on completion, cancel, and expiry.
- `WaitingMakerBond` orders expire via the scheduler, marked `Expired`
  directly in the DB without a NIP-33 republish (the order never appeared
  in the book — no ghost entry, §10.4) and any bond row released.
- `nip33::create_status_tags` emits no event for `WaitingMakerBond`.
- Take handlers gate on the new `trade_committed_by_locked_taker_bond`
  helper (extracted from the duplicated inline check) so a `Locked`
  maker bond — the steady state under `apply_to = both` — does not block
  takers with `PendingOrderExists`.

Out of scope (deferred per §4): range maker orders (Phase 6) and maker
timeout slash (Phase 7); the timeout gate stays `applies_to_taker()`.

Bumps mostro-core to 0.12.1 (ships `Status::WaitingMakerBond`). That
release also carries the Cashu F1 escrow fields on the `Order` model, so
the 20260530120000_cashu_escrow_fields.sql migration is included to keep
`Order::by_id` SELECT consistent with the new columns.

Tests: maker bond singleton lock + idempotency, lock isolation, dispute
slash on both order kinds, `WaitingMakerBond` not published on wire,
expiry inclusion in `find_order_by_date`, maker-bond notional, and the
locked-maker-bond-does-not-commit-the-trade gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): scope taker-flow bond checks to role='taker' under apply_to=both

Addresses review P1: under `apply_to = both` a `Locked` maker bond is
present on every published order, and three taker-flow checks counted it
as if it were a competing taker bond — breaking the taker flow whenever
the maker side is also bonded:

1. `on_bond_invoice_accepted` first-to-lock-wins CAS: the `NOT EXISTS`
   guard matched any `Locked` bond, so the first taker to pay saw the
   maker bond, its UPDATE affected zero rows, and the taker was wrongly
   treated as a race loser (bond released, take cancelled). Every taker
   on a maker-bonded order was rejected. Guard now filters `role='taker'`.
2. `maybe_drop_waiting_taker_bond`: the "no active bonds remain → drop to
   Pending" CAS counted the maker bond, so a `WaitingTakerBond` order
   whose last taker bond was cancelled never returned to `Pending`.
   Active-bond check now scoped to `role='taker'`.
3. `cancel_order_by_taker` `others_remain`: the lingering maker bond
   (pubkey != cancelling taker) looked like "another taker still racing",
   so a lone taker's self-cancel never reset the order. Now counts only
   taker bonds.

The maker bond stays `Locked` and untouched throughout the taker flow;
`edit_pubkeys_order` only clears the taker side, so the maker pubkey the
bond's slash resolution relies on is preserved across a taker reset.

Tests: taker wins the lock race with a `Locked` maker bond present;
`WaitingTakerBond` drops to `Pending` ignoring the maker bond. The race
test's local CAS mirror is hoisted to a shared `try_lock` helper kept in
lockstep with the production query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(bond): testable drop-to-pending helper + range-maker skip log

Addresses self-review follow-ups on the Phase 5 PR:

- Extract the `WaitingTakerBond → Pending` CAS out of
  `maybe_drop_waiting_taker_bond` into
  `drop_waiting_taker_bond_to_pending`, a side-effect-free helper
  returning whether the transition applied. The wrapper keeps the
  NIP-33 republish (which needs process-wide keys). The regression test
  now exercises the real helper instead of an inlined SQL copy that
  could drift from production, and a new test asserts an active *taker*
  bond still pins the order (the role filter must not over-drop).

- Log a `warn!` when a range order is published under
  `apply_to ∈ { make, both }` without a maker bond (range-maker bonds
  are Phase 6). Without it an operator could wrongly assume every order
  on a bond-enabled node is bonded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(bond): correct cashu-fields version reference to mostro-core 0.12.1

The migration header and two test-setup comments referenced
"mostro-core 0.12.0", but the project pins 0.12.1 in Cargo.toml. Align
the comments with the pinned version for consistency. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): cleanup stranded maker order + CAS deferred publish

Address two races flagged in review of Phase 5 maker bond.

- publish_order: if request_maker_bond fails after the order row is
  parked at WaitingMakerBond, delete the stranded row (scoped to that
  status) instead of leaving a hidden order until the expiry job reaps
  it hours later. The order never emitted a NIP-33 event and any bond
  row was already released, so deletion is safe; the error is surfaced
  to the maker.

- resume_publish_after_maker_bond: guard the deferred publish with an
  atomic WaitingMakerBond -> Pending compare-and-swap. The subscriber's
  prior status re-read is not atomic with the publish, so the expiry job
  could flip the row to Expired (and cancel the locked bond) in between;
  the full-row write in finalize_order_publication would then resurrect
  the dead order. On rows_affected != 1 we skip cleanly, mirroring the
  existing taker-side CAS pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): retain maker bond when take timeout republishes order

When a take attempt times out in a waiting state and the order is
republished to the book (the taker is the responsible party —
WaitingBuyerInvoice/sell, WaitingPayment/buy), the maker is still
committed to the order. The scheduler's timeout path was releasing the
maker's Locked bond via apply_bond_resolution, leaving a takeable order
in the book with no maker bond backing it.

slash_or_release_on_timeout now distinguishes republish from terminal
cancel: on republish only the abandoning taker bond is resolved (slashed
or released) and maker bonds are retained Locked, to be released only
when the order itself terminates. Adds release_taker_bonds_for_order_or_warn
and routes the slash path through slash_one directly so non-slashed maker
bonds survive a republish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a table covering all Nostr event kinds used by the daemon\n- Clarify that kinds 1 and 13 live only inside GiftWrap transport\n- Fix README examples that incorrectly labeled the Mostro info kind as 38383

Co-authored-by: Hermemes <hermemes@users.noreply.github.com>
…P#753)

* feat(price): Phase 1 — Yadio provider + PriceManager wiring (single-source parity)

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

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

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

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

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

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

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

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

* fix(price): track real contributors, one-shot stale warning, scheduler outage log

Addresses the three review findings on MostroP2P#753.

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

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

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

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

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

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

* fix(price): track outlier-surviving contributors through aggregate_tick

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

Threads provenance through the pure-function aggregation core instead:

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

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

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

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

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

* test(price): pin exact contributor list in bimodal-fallback test

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

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

* fix(price): make legacy bitcoin_price_api_url optional + unify live Yadio URL

Human-review follow-ups on Phase 1 (spec §10.1 backward compatibility):

- types.rs: add #[serde(default)] to bitcoin_price_api_url so a settings.toml
  that has migrated to a [price] block may omit the deprecated key instead of
  failing deserialization and aborting startup (arkanoider, codaMW). Field
  stays — util.rs and install_price_manager() still read it until Phase 4/5.
- settings.tpl.toml: document the key's full lifecycle (now optional, still
  read by the live /convert path until Phase 4, removed in Phase 5).
- util.rs: route the live /convert + /currencies path through a yadio_base_url()
  helper that prefers [price.providers.yadio].url when a [price] block is
  present, falling back to the legacy key. Stops the live and cached paths
  silently hitting different Yadio bases when only the new key is customised.
- config.rs / manager.rs: point the three [price] validation errors at
  docs/PRICE_PROVIDERS.md §7 and make the unimplemented-provider message
  actionable.
- tests: prove a [mostro] block without bitcoin_price_api_url deserializes to
  the default URL, and that legacy synthesis is identical whether the key is
  present-at-default or omitted.

Does not touch price/aggregate.rs, price/store.rs, or the scheduler tick
(§5.4 extension-contract invariant holds).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(price): gate + normalize the live Yadio base URL

ermeme review on PR MostroP2P#753: the live-path Yadio URL helper could emit broken
URLs. It only `trim()`med (not `trim_end_matches('/')` like
`YadioProvider::new`), so a configured trailing slash produced `//convert` /
`//currencies` and diverged from the aggregate path the helper exists to match.
It also used the `[price.providers.yadio]` URL even when the provider was
disabled, overriding the legacy `bitcoin_price_api_url` fallback.

- Normalize the chosen URL with `trim_end_matches('/')`, matching
  `YadioProvider::new` (applied to the legacy fallback too).
- Use the provider URL only when *usable*: enabled and non-empty after
  normalization; otherwise fall back to the legacy key.

Split the selection out into a pure `select_yadio_base_url` so it is unit
testable without the write-once global `Settings`; add tests for the
prefer-enabled, strip-trailing-slash, and fall-back-when-unusable cases.

(The review's "empty config suppresses the fallback" point did not hold — the
existing `!url.is_empty()` guard already fell through — but the trailing-slash
and disabled-provider issues were real.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MostroP2P#770)

* feat(bond): Phase 6 — range-order maker bond with proportional slashes

Range maker orders now post a single anti-abuse bond, sized against
`max_amount`, that is slashed proportionally per taken slice and settled
once at range close (Option A — accumulate-and-settle-at-close, no
liquidity fronting). Daemon-only: reuses the Phase 2 dispute, Phase 3
payout and Phase 5 maker mechanisms; no `mostro-core` change and no new
migration (the Phase 0 schema already carries the parent/child columns).

- publish_order: size the maker bond against `max_amount` for range
  orders (was deferred in Phase 5); `maker_bond_notional_sats` gains a
  range branch.
- The maker bond lives on the range *root*; a slash on any slice walks
  `range_parent_id` to it (`find_maker_bond_for_order`). A maker slice
  slash inserts a child row (`PendingPayout`) and accumulates
  `slashed_share_sats` WITHOUT settling the parent HTLC.
- Slash share is price-invariant — `slice.fiat_amount / root.max_amount`
  (both fiat) — so no `parent_max_sats` column is needed; clamped to the
  bond amount.
- Phase 3 payout scheduler skips child rows while the parent is `Locked`
  (`child_payout_blocked_by_locked_parent`); `resolve_payout_recipient`
  pays the maker directly for the unslashed-remainder refund row.
- resolve_range_maker_bond_at_close: at range close settle the parent
  once (→ `Slashed`), pay each child counterparty, refund the unslashed
  remainder to the maker; release (cancel HTLC) when no slice was
  slashed. Idempotent CAS. Wired into release_action (no remainder),
  admin_settle/admin_cancel, the cancel.rs termination paths, and the
  scheduler's pending-expiry.
- Tests: proportional/clamped slice slash, settle-once + refund at close,
  no-slash release, apply records child without settling, chain-walk from
  a descendant slice, payout parent-locked guard, refund/slice recipient
  resolution. cargo test/clippy/fmt green (399 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(bond): reference PR MostroP2P#770 for Phase 6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): address Phase 6 review — claim-window anchor, max guard, close-on-err

- payout/close: anchor each slice child's payout claim window at range
  *close* time (re-stamp slashed_at in resolve_range_maker_bond_at_close),
  not at slice-slash time, so a long-open range can't forfeit a child the
  instant it becomes payable. The refund row already used close time.
- util: maker_bond_notional_sats rejects a non-positive max_amount for
  range orders (is_range_order only checks Some-ness), matching the guard
  in record_maker_slice_slash; prevents a divide-by-zero in the slash.
- release: on get_child_order error, resolve the maker bond at close
  instead of leaving it Locked until LND CLTV — get_child_order only
  computes (no child is persisted/published on error), so no remainder
  exists.
- tests: range-order bond sizing (success conversion via a new cfg(test)
  price seeder + non-positive-max rejection) and a close re-anchor test.

Skipped (not valid against current code):
- "gate Expired commit on close success" — contradicts the best-effort
  bond design (spec §8.2): bond resolution must never block order
  lifecycle; gating on LND availability would strand orders Pending.
- release.rs retry infrastructure for get_child_order — over-scoped;
  mostro retries child-order creation nowhere (pre-existing limitation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): Phase 6 review round 2 — idempotent slice slash + structured log

- slash: make record_maker_slice_slash idempotent per (parent_bond_id,
  child_order_id) — guard against inserting a second slash row for the
  same slice, which the close path would double-count and over-slash/
  over-pay. The admin handlers already block a retry via their order-
  status guard (status moves off Dispute before apply_bond_resolution),
  so this is defensive and keeps the "one slash row per slice" invariant
  true for any future caller (e.g. the Phase 7 maker timeout slash). No
  migration; bounded by per-order admin serialization.
- release: use structured tracing fields (order_id, error) for the
  get_child_order failure warning instead of an interpolated string.
- tests: record_maker_slice_slash_is_idempotent_per_slice; the clamp
  test now uses two distinct slice orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): make range slice-slash allocation atomic

The per-(parent_bond_id, child_order_id) idempotency check in
record_maker_slice_slash was a read-then-create, i.e. TOCTOU. admin
settle/cancel has two independent entry points — the serial Nostr loop
and the RPC service, each with its own LND client — and admin_cancel has
no order-status CAS (unlike admin_settle), so two concurrent duplicate
cancels could both pass the existence check and both insert a
PendingPayout child against the same single HTLC, over-allocating the
parent at close.

Replace the check + create_bond with a single atomic
`INSERT ... WHERE NOT EXISTS`, which is atomic under SQLite's write lock:
the loser sees rows_affected = 0 and skips. No migration; the parent's
slashed_share_sats recompute already derives from the actual child rows
so it stays correct. The existing idempotency test now exercises this
path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bond): Phase 6 hardening — unique index + stranded-close reconciliation

Defense-in-depth follow-ups on the range-order maker bond:

- Add a partial UNIQUE index on bonds(parent_bond_id, child_order_id)
  so the "one slash row per slice" invariant holds at the schema level
  for any future caller, not just record_maker_slice_slash's atomic
  INSERT ... WHERE NOT EXISTS. Treat a constraint violation as the same
  idempotent no-op (sqlx 0.6 has no is_unique_violation; match on the
  extended code / message).
- Add a periodic scheduler sweep (job_reconcile_stranded_maker_bonds)
  that retries resolve_range_maker_bond_at_close for any maker bond left
  Locked after a transient close failure, once its whole range tree is
  terminal. The close is idempotent (CAS), so a close failure no longer
  relies solely on the CLTV safety net. Open ranges are never touched.
- Add a wallet-accounting invariant test: across 2+ slashed slices the
  child rows (slices + maker refund) sum to the parent bond exactly and
  the refund absorbs any rounding remainder — no sat created or lost.
- Document both in docs/ANTI_ABUSE_BOND.md §11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): cycle-safe range-tree walk + per-root sweep isolation

Addresses CodeRabbit review 4476618573 plus a related robustness follow-up:

- db.rs: the recursive CTE in `range_tree_fully_terminal` walked
  `range_parent_id` downward with `UNION ALL` and no cycle/depth guard, so a
  corrupt cycle could loop unbounded and hang the scheduler tick. Switch to
  `UNION` (dedup breaks any cycle); the result is only reduced to
  `COUNT(*) == 0`, so dedup doesn't change the semantics. Mirrors the
  `MAX_RANGE_CHAIN_DEPTH` guard on the upward walk.
- slash.rs: `collect_stranded_range_maker_roots` propagated per-root DB errors
  via `?`, so one bad root aborted the whole reconciliation tick and starved
  retries for every other stranded bond. Log + `continue` per root instead
  (best-effort, §8.2).
- Tests: cycle-safe tree walk (A↔B returns promptly), and per-root isolation
  (a bad/orphaned root is skipped while a valid stranded root still resolves).
- docs §11: note the sweep is cycle-safe and isolates per-root failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): close range maker bond atomically to kill the partial-close window

Addresses ermeme review 4477194281. The close flipped the parent
Locked → Slashed via CAS BEFORE the dependent work (maker refund row, child
slashed_at re-anchor) was committed. A crash/DB failure after the CAS left a
Slashed-but-incomplete parent the Locked-keyed reconciliation sweep never
retries — silently dropping the maker refund and forfeiting children on a
stale anchor.

Eliminate the window instead of repairing it:
- Settle the parent HTLC FIRST, while still Locked (already idempotent-
  tolerant via is_already_settled_error, so a retry re-settles harmlessly).
- Then run the CAS, the child re-anchor, and the refund INSERT in ONE
  sqlx transaction (pool.begin → execute ×3 → commit). The refund row is now
  a raw INSERT (mirrors record_maker_slice_slash) so it shares the tx. The
  CAS stays inside the tx: a racing loser sees rows_affected = 0 and rolls
  back with no side effects.

After commit, Slashed always means "close fully done"; Locked is the sole
in-flight state, so the existing sweep covers every crash point. No new
reconciliation path for partially-closed Slashed parents. Public signatures,
the sweep, and the payout scheduler are untouched.

Tests: range_close_crash_after_settle_is_resumed (trigger-injected tx failure
→ parent stays Locked, no refund row, child not re-anchored; retry with
"already settled" completes; settle twice, rows once) and
settle_already_settled_is_treated_as_success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bond): close the stale-snapshot over-refund race at range close

ermeme review on PR MostroP2P#770: `slice_children`/`total_slashed` were read before
the close transaction began, so a slice slash committing between that snapshot
and the `Locked → Slashed` CAS was missed. The late child still got paid by the
scheduler while the maker refund was computed from the stale total, pushing the
distributed total (children + refund) past the single settled HTLC.

Two-part fix that locks the close against concurrent slice slashes:

- Gate the slice-slash INSERT on the parent still being `Locked`
  (`AND EXISTS (... state = Locked)`). Once the close wins its CAS, no further
  child can be inserted; a slice that misses the window is dropped (the safe
  direction — the HTLC amount is already fixed).
- Recompute `total_slashed` authoritatively from the child rows *inside* the
  close transaction (after the CAS holds the write lock), and derive the refund
  from that instead of the pre-transaction snapshot.

Add a regression test asserting a slash is a no-op once the parent has left
`Locked`. Existing two-slice conservation test covers the in-tx recompute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stroP2P#770 merge skew (MostroP2P#774)

PR MostroP2P#753 removed the BITCOIN_PRICES static when it migrated price reads
to the PriceManager, while PR MostroP2P#770 (merged independently) added
BitcoinPriceManager::set_price_for_test writing to that static — the
combination doesn't compile under cargo test on main.

Re-point the test seam at a small cfg(test) override map consulted by
price::get_bitcoin_price before the global manager, so unit tests keep
seeding deterministic prices without installing the global PriceManager
(whose OnceLock would leak one test's configuration into the rest of
the binary).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(price): repair test-only price seeding broken by MostroP2P#753/MostroP2P#770 merge skew

PR MostroP2P#753 removed the BITCOIN_PRICES static when it migrated price reads
to the PriceManager, while PR MostroP2P#770 (merged independently) added
BitcoinPriceManager::set_price_for_test writing to that static — the
combination doesn't compile under cargo test on main.

Re-point the test seam at a small cfg(test) override map consulted by
price::get_bitcoin_price before the global manager, so unit tests keep
seeding deterministic prices without installing the global PriceManager
(whose OnceLock would leak one test's configuration into the rest of
the binary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bond): Phase 7 — maker timeout slash

Fill the maker-responsible rows of the §9.2 responsibility table: a
waiting-state timeout now slashes the maker's bond when the maker is
the responsible party, gated per posting role (apply_to must cover the
responsible side — taker since Phase 4, maker since Phase 7).

- slash_or_release_on_timeout resolves the responsible bond through the
  range-aware resolve_slash_target (the maker bond of a range order
  lives on the range root), and gates the slash on applies_to_maker()
  / applies_to_taker() per the responsible side.
- A maker-responsible timeout on a range order goes through the Phase 6
  partial-slash path: record_maker_slice_slash inserts a proportional
  child row with reason=Timeout and the parent HTLC stays Locked; the
  single settle happens at range close. The helper now returns whether
  it inserted, so the BondSlashed notice fires exactly once (a
  scheduler retry that finds the child already recorded reports None).
- The scheduler's terminal cancel branch now runs
  resolve_range_maker_bond_at_close_or_warn after the Canceled status
  persists, so a slashed range settles and distributes promptly instead
  of waiting for the 5-minute reconciliation sweep (which remains the
  backstop). The republish branch never closes — the maker stays
  committed there.
- The timeout release loop retains a range maker parent bond (resolved
  only at range close), alongside the existing republish carve-out.

Tests mirror Phase 4 from the maker side: non-range sell/buy slashes,
the per-role apply_to gate, and the range path (proportional child,
parent stays Locked, root resolution from a descendant slice,
per-slice idempotency without re-notification).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(bond): mark Phase 7 shipped + implementation notes; fix stale Phase 4.5 row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…2P#777)

* docs(bond): Phase 8 — public config exposure + operator docs

Final phase of the anti-abuse bond rollout (spec §13). The load-bearing
code — the kind-38385 info-event policy tags (`bond_enabled`,
`bond_apply_to`, `bond_amount_pct`, …) — already shipped in Phase 3
(MostroP2P#738) with tests, so Phase 8 is documentation polish only. No code,
no migration, no mostro-core change.

- README: anti-abuse bond feature bullet + a "How It Works" overview
  covering the maker/taker vs buyer/seller axes and the lock → release →
  slash lifecycle, plus the info-event exposure.
- docs/ARCHITECTURE.md: bond modules in the action map, per-action
  summaries, the §3.1 axes note, and a Lock → Resolve sequence diagram.
- docs/LIGHTNING_OPS.md: operator runbook — bonds-table inspection, the
  state machine, scheduler jobs, BondResolution wire format, and how to
  handle a `failed` bond.
- docs/README.md: index entry.
- docs/ANTI_ABUSE_BOND.md: mark Phase 8 complete; the feature is done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(bond): reference PR MostroP2P#777 in Phase 8 spec status

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(bond): fix payout action in diagram + .env fence language

- ARCHITECTURE.md: the winner submits the bond payout bolt11 via
  Action::AddBondInvoice (app.rs routes it to add_bond_invoice_action),
  not Action::AddInvoice. Correct the Lock → Resolve sequence diagram.
- README.md: add 'bash' language to the MOSTRO_NSEC_PRIVKEY .env fenced
  block (MD040).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on (MostroP2P#773)

* feat(price): Phase 2 — direct backup quoters + multi-source aggregation

Implements Phase 2 of docs/PRICE_PROVIDERS.md: the system becomes
genuinely multi-source. Three keyless direct (PerBtc) adapters join
Yadio, each fixture-tested against a captured live payload (§10.5):

- coingecko: GET /simple/price over a baked fiat vs_currencies list;
  optional demo/pro api_key (header picked from the host), redacted
  from Debug/logs per §10.3.
- currency_api: GET /currencies/btc.min.json; lowercase codes
  upper-cased (§6.6); implements `fallback_urls` — mirrors tried in
  order, the provider only fails when every URL fails (§7). Fallback
  behaviour covered by a local-axum integration test.
- blockchain: GET /ticker taking `last` (mid-market) only; buy/sell
  are not even deserialised (§6.6/§11.6).

Manager (spec §5.3, §6.5, §6.6):
- Providers are polled concurrently (join_all), each fetch bounded by
  provider_timeout_seconds — tick wall-clock = slowest provider, not
  the sum.
- Circuit breaker wired: Phase 0's ProviderHealth now gates polling;
  cooldown-skipped providers land in the new TickReport.skipped
  (neither success nor failure). Backoff cap fixed at 1800s (§6.5).
- §6.6 fiat allowlist (new price::fiat): ISO-4217 active codes plus
  the non-ISO fiat Yadio really trades (IRT, GGP/IMP/JEP, MLC, XCG).
  Against the live Yadio feed this drops exactly BTC/XAU/XAG/XPT.
  VEF is deliberately excluded (pre-redenomination unit vs VES); the
  CoinGecko request list omits it for the same reason.

Phase 2 acceptance tests (§9): median+outlier across 4 providers,
lowercase/uppercase combine, official-CUP scoped out by `except`
(2-source case the outlier guard can't save), non-fiat dropped by
allowlist, provider-down fallback, breaker open-skip and
reset-after-success. 431 tests green.

Config: settings.tpl.toml gains the three §7 provider blocks
(currency_api ships except = ["CUP", "MLC"]). El Toque remains
rejected at startup until Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(price): cover the full mirror sequence in the poll budget + fresh breaker timestamps

Address PR MostroP2P#773 review findings:

- Codex P2 (valid): the outer per-provider timeout equalled the reqwest
  per-request timeout, so a *hanging* currency_api primary consumed the
  whole budget and the fallback_urls were dead code in exactly the hung
  case (they only worked for fast failures like connection-refused).
  New `poll_budget(id)` sizes the outer tokio timeout to
  provider_timeout_seconds × (1 + fallback_urls) + 1s slack; the
  per-attempt bound remains the shared reqwest client's request timeout.
  Adds a manager unit test for the scaling and an adapter-level axum
  test proving a hung primary is cut at the request timeout and the
  mirror still answers.

- CodeRabbit Major (valid, minor impact): record_failure reused the
  pre-poll `now`, so breaker cooldowns were born already aged by up to
  a full poll budget. Failures are now stamped after polling completes.

- CodeRabbit nit (declined): tracing spans around the polling — the
  codebase uses zero spans anywhere; per-provider outcomes already log
  the provider id inline, so spans here would be inconsistent house
  style for little correlation gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rod (MostroP2P#776)

* feat(transport): Phase 1 — wire protocol v2 (NIP-44 direct) into mostrod

Phase 1 of the Messaging Transport Abstraction Layer (MostroP2P#626), on top of
mostro-core 0.13.0 (Phase 0, MostroP2P/mostro-core#152). The node now
speaks the operator-configured transport — protocol v1 gift wraps
(kind 1059, DEPRECATED) or protocol v2 signed kind-14 events with
NIP-44 encrypted content — with zero handler changes: both transports
unwrap into the same UnwrappedMessage via mostro-core's kind dispatch.

- Bump mostro-core 0.12.1 -> 0.13.0 (protocol v2 transport module,
  PROTOCOL_VER = 2).
- New `[mostro] transport` setting ("gift-wrap" | "nip44", serde
  default gift-wrap so existing settings.toml files keep working and
  the wire behavior is identical to pre-v2 daemons).
- New `[expiration] dm_days` knob (default 30): kind-14 events always
  carry a NIP-40 expiration tag; send_dm() fills it on the nip44
  transport when the caller didn't pass one.
- main.rs subscribes to the configured transport's kind only; app.rs
  accepts only that kind and unwraps via unwrap_incoming().
- Kind-38385 info event advertises `protocol_versions` ("1" or "2")
  so clients pick the right wire format before sending.
- docs/TRANSPORT_V2_SPEC.md: full context, v2 wire format (including
  the trade-key-bound identity proof), versioning, operator config,
  release timeline (v1 DEPRECATED in 0.18.0, removed in 0.19.0) and
  the phase guide (Phase 2 anti-spam gates, Phase 3 protocol docs,
  Phase 4 cutover).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transport): non-panicking transport accessor + MD040 fence

Codex P2: send_dm() called Settings::get_mostro(), which panics when
the global MOSTRO_CONFIG isn't initialized — turning the previously
fallible call into an abort in unit tests that don't bring up the full
configuration (test_send_dm only survived by test-ordering luck).
Add Settings::get_transport(), which falls back to the gift-wrap
default when settings are absent, mirroring the existing get_bond /
get_price non-panicking accessors. The nip44 expiration path is
unreachable without initialized settings (the fallback is gift-wrap),
so no other panic path is introduced.

Note: test_send_dm's assertion was already order-dependent on main
(it expects Err, which only happens when another test has installed
NOSTR_CLIENT) — unchanged by this PR.

Also tag the identity-proof payload fence in the SPEC as `text`
(markdownlint MD040, flagged by Codex and CodeRabbit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…stroP2P#779)

* refactor(bond): generalize slash confirmation witness

  - Extract slash_reason_recorded(pool, bond_id, expected) from timeout_slash_confirmed
  - timeout_slash_confirmed now delegates to it keyed on BondSlashReason::Timeout
  - Pure refactor, no behavior change

* feat(bond): notify slashed party on dispute slash (MostroP2P#768)

  - apply_bond_resolution returns the confirmed-slashed bond rows
  - non-range/taker rows confirmed via slash_reason_recorded(LostDispute)
  - range maker rows returned only when the slice child row is newly inserted
  - admin_settle/admin_cancel send a best-effort BondSlashed notice per row
  - transient settle failures and idempotent retries yield no row, so a notice
    is never untruthful and a winner is never re-notified

* test(bond): cover dispute-slash forfeiture notice

  - apply_bond_resolution returns one confirmed row per slashed side
  - both-sides slash returns both rows; null payload returns none
  - transient settle failure returns no row (notice never untruthful)
  - non-range and range retries return no row (winner never re-notified)
  - range slash returns the child slice row with the slice amount

* docs(bond): dispute slash sends BondSlashed
…#780)

Implements Phase 2 of docs/TRANSPORT_V2_SPEC.md (§6, issue MostroP2P#626): the payoff
of the kind-14 transport — reject junk BEFORE paying the NIP-44 decrypt cost.
v2-only; the gift-wrap (v1) path is untouched (its outer key is a throwaway
with no pre-validatable signal). Zero handler changes — the gate lives
entirely in the event-loop preamble.

Design (confirmed with maintainer):
- Dedicated `pow_first_contact` knob: known active-trade keys need only the
  base `pow`; unseen (first-contact) senders must clear `pow_first_contact`
  before decryption. Defaults to `pow`, so existing configs are wire-identical.
- Active-trade-pubkey cache refreshed by a periodic scheduler job (status
  mutations are scattered with no choke-point, so a full reload is the robust,
  low-coupling strategy) + warmed at startup.

Changes:
- src/spam_gate.rs (new): `SpamGate` global (OnceLock, mirrors PriceManager) —
  known-keys cache (RwLock<HashSet>) + a REPLAY_WINDOW_SECS (60s) replay guard
  for dedup. Unit-tested (membership/replace, replay dedup, window expiry,
  prune bound).
- src/db.rs: `find_active_trade_pubkeys` — buyer/seller/creator of every
  non-terminal order + solver of every active dispute. New
  TERMINAL_ORDER_STATUSES = EXCLUDED_ORDER_STATUSES minus 'dispute' (a disputed
  order is still active). DB test covers the active/disputed/terminal nuance.
- src/app.rs: the gate in the event loop (v2 only) — dedup replay drop, then
  known-keys fast-path vs first-contact `pow_first_contact` gate, all before
  unwrap_incoming.
- src/config/types.rs: `[mostro] pow_first_contact` (Option<u8>) +
  `active_pubkeys_refresh_interval` (u64, default 60), both #[serde(default)];
  `effective_pow_first_contact()` accessor. Tests incl. legacy-config parse.
- src/scheduler.rs: `job_refresh_active_pubkeys`.
- src/main.rs: install + warm the gate before the event loop.
- settings.tpl.toml + docs/TRANSPORT_V2_SPEC.md: document the new knobs and
  mark Phase 2 done.

Tests: 482 passed; cargo clippy --all-targets --all-features -D warnings clean;
cargo fmt clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emit an info line on boot showing the configured transport name,
protocol version, and event kind so operators can confirm at a glance
whether the node speaks gift-wrap (v1) or nip44 (v2).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stroP2P#782)

Client and node are compatible only when they run the same protocol
version, so the kind-38385 info tag carries a single value ("1" or "2"),
not a list. Rename the emitted tag (and docs) to the singular form to
match the protocol spec and the mostro-cli reader.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…P2P#778)

* feat(price): Phase 3 — El Toque fiat-cross provider (CUP/MLC), provisional wiring

Implements Phase 3 of docs/PRICE_PROVIDERS.md (§9, §11.3): the El Toque
provider, a fiat-cross quoter that contributes CUP and MLC as
`Quote::PerBase { base: "USD" }`, resolved against the aggregated USD/BTC
anchor by Phase 0's existing `resolve_per_base` (no aggregation-core change).

Per §5.4 this is one adapter file + one registry arm + one config block +
one fixture — the aggregation core, store, scheduler and handlers are
untouched.

- src/price/providers/eltoque.rs: ElToqueProvider. Parses El Toque's
  CUP-denominated `tasas` payload and emits:
    CUP -> PerBase{USD, cup_per_usd}
    MLC -> PerBase{USD, cup_per_usd / cup_per_mlc}   (§11.3 cross math)
  Bearer-token auth (required when enabled -> startup error otherwise,
  §7); token redacted from Debug/logs (§10.3). Anchor = USD only (the
  §11.3 EUR-fallback question was declined for this phase).
- manager.rs: registry arm builds El Toque (fails fast without a token);
  the old "unimplemented" rejection and its test are replaced with
  with-token / without-token coverage.
- settings.tpl.toml: real [price.providers.eltoque] block (enabled=false).
- docs/PRICE_PROVIDERS.md: Phase 3 marked in review; §11.3 status note.

PROVISIONAL: the tasas API is token-gated, so a real payload could not be
captured. The parse path is grounded in the confirmed CUP-denominated
`tasas` shape and is fully unit-tested; the request line in `fetch` (path
/v1/trmi, GET, no date params) is best-effort from third-party
reverse-engineering and the shipped fixture is a representative sample,
not a capture. All clearly flagged in comments and the spec — to be
finalised against the official docs in a follow-up. Keep enabled=false in
production until then.

Tests: 8 new El Toque adapter tests + updated registry tests. Full suite
472 passed; cargo clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(price): Phase 3 — confirm El Toque request line and capture fixture

Apply the El Toque API details confirmed with the maintainer, finalising
the previously-provisional request wiring:

- fetch now sends the required [date_from, date_to] range:
  GET /v1/trmi?date_from=…&date_to=… (YYYY-MM-DD HH:MM:SS, URL-encoded
  by reqwest), Bearer auth unchanged. The endpoint returns the most
  recent rate within the range, so we query a rolling 48h window ending
  "now" (LOOKBACK_HOURS) — wide enough to absorb the ~daily TRMI update
  cadence and UTC↔Cuba skew without returning empty tasas.
- tests/fixtures/price/eltoque_trmi.json: replaced the representative
  sample with a real captured response. This also corrects the timestamp
  shape — hour/minutes/seconds are separate integers, not a "HH:MM:SS"
  string (the parser ignores them either way).
- Dropped the PROVISIONAL/reverse-engineering language from the module
  doc and docs/PRICE_PROVIDERS.md; documented the confirmed request and
  response shapes. Tests updated to the captured values (USD=490,
  MLC=200 → MLC/USD = 490/200).

Q1/Q3 (§11.3 maintainer questions) remain open; provider stays
enabled=false until a token is provisioned.

Tests: full price suite 91 passed; cargo clippy --all-targets + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(price): El Toque window must be under 24h (was 48h → 400)

The /v1/trmi endpoint rejects any [date_from, date_to] range of 24h or
more with `400 "El intervalo de tiempo debe ser menor a 24 horas"`, so
every poll failed regardless of token. Lower LOOKBACK_HOURS from 48 to
23 (1h margin under the hard cap, still spanning a full daily TRMI
cycle) and document the constraint.

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

* feat(price): warn when legacy bitcoin_price_api_url is ignored

When `[price]` is configured the multi-source manager drives aggregation
and the legacy `[mostro].bitcoin_price_api_url` is not consulted. Emit a
startup WARN naming the legacy value and the enabled providers so an
operator who still has the legacy key set isn't misled into thinking it
takes effect.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#752)

* fix: return CantDo(NotFound) for missing orders instead of InvalidOrderId

* fix: format order_id extraction in get_order (no functional change)
…rt (MostroP2P#785)

`MessageKind::new` always stamps the crate-wide `PROTOCOL_VER` (= 2), so every
server reply advertised protocol v2 even when served over the v1 gift-wrap
transport. Stamp the inner message version in `send_dm` — the single wrap
chokepoint — so it follows the negotiated wire format: `gift-wrap` -> v1,
`nip44` -> v2.

Done before wrapping so the version is covered by the message/trade
signatures. Logic extracted into `stamp_protocol_version` for unit testing.


Claude-Session: https://claude.ai/code/session_0126WFgFsxsvCewFb9pxfT7v

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ain (MostroP2P#787)

Full mutation testing is slow (one test-suite run per mutant) and the job is
`continue-on-error`, so running the baseline on every push to `main` gated
nothing and only burned CI minutes. Drop the `push: [main]` trigger; the
baseline now runs weekly (`schedule`) and on demand (`workflow_dispatch`),
alongside the existing opt-in per-PR job (`run-mutation` label).

Also swap `cargo install cargo-mutants` (compiles from source on every run)
for `taiki-e/install-action` (prebuilt binaries) in both jobs.

Docs updated to describe the new triggers and point to `cargo mutants
--in-diff` for pre-push feedback.


Claude-Session: https://claude.ai/code/session_0126WFgFsxsvCewFb9pxfT7v

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses review feedback on the Cashu Track B release path.

CI:
- Remove unused `mint_url` field and three useless `cdk::Error` conversions
  in src/cashu/mod.rs (clippy -D warnings) and apply rustfmt.

Release path (src/app/release.rs):
- Compute and validate the P_M proof signatures before mutating any state.
  If token parsing, key derivation or signing fails, or no proofs are
  produced, return an error and leave the order in FiatSent/Dispute so the
  release stays retryable instead of marking it Success with the buyer
  unable to redeem the escrow.
- Replace the unconditional unwrap() on cashu_escrow_token with a typed error.
- Skip the Lightning-only Released and HoldInvoicePaymentSettled
  notifications in Cashu mode.

Other:
- escrow.rs: CashuBackend stubs return typed errors instead of unimplemented!()
  so an accidental instantiation cannot panic the daemon.
- cashu/mod.rs: drop the never-read CASHU_STATUS global (connect() already
  reports failure via Err) and implement std::error::Error for cashu::Error.
- db.rs: bind Status::Active in find_locked_cashu_orders instead of a literal.
- sqlx-data.json: add trailing newline.
- docker-compose.cashu.yml: document MINT_PRIVATE_KEY as a test-only fixture.
@grunch

grunch commented Jun 24, 2026

Copy link
Copy Markdown
Member

Hi @a1denvalu3 — thanks for this work! I pushed a commit directly to the branch (2514856) with some fixes so the PR is mergeable. Happy to revert or move any of it to a separate PR if you'd prefer to handle it yourself.

What I changed

CI was red, so first the blockers:

  • clippy -D warnings: removed the unused mint_url field on CashuClient and three cdk::Error::from(e) useless conversions in src/cashu/mod.rs.
  • Applied rustfmt.

Then hardening of the release happy path:

  • release_action ordering (funds-at-risk): the order was committed to Success and PurchaseCompleted was sent before the P_M signatures were computed, and a signing failure was silently swallowed — leaving the buyer told "done" with no way to redeem the escrow. Reordered so signatures are computed and validated first; on any failure (token parse, key derivation, signing, or zero proofs) it returns an error and leaves the order in FiatSent/Dispute so the release stays retryable.
  • Replaced the unconditional unwrap() on cashu_escrow_token with a typed error.
  • Skipped the Lightning-only Released / HoldInvoicePaymentSettled notifications in Cashu mode.
  • escrow.rs: CashuBackend stubs now return typed errors instead of unimplemented!() (no panic if ever instantiated).
  • Dropped the never-read CASHU_STATUS global, implemented std::error::Error for cashu::Error, bound Status::Active in find_locked_cashu_orders, added the sqlx-data.json trailing newline, and documented MINT_PRIVATE_KEY as a test-only fixture.

Verified locally: cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test (385 passed) all green.

Left for you / a follow-up (not addressed here)

The biggest one is a security gap I intentionally did not touch: the release path never re-verifies the escrow token against the mint. verify_2of3_condition, verify_token_dleq and check_state exist but are never called, so the release trusts the token stored at lock time without re-checking it's valid/unspent/bound to the right pubkeys at release time. Wiring this needs mint connectivity at release plus the p_b/p_s/p_m pubkeys, which feels like Track-level work and your design call — I didn't want to half-implement a crypto check. Suggest a dedicated follow-up issue/PR.

Smaller items also left as-is: run/run_cashu event-loop duplication, the mint_url: StringOption<String> nit, and a token-length bound on the column.

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	migrations/20260530120000_cashu_escrow_fields.sql
#	src/app/bond/flow.rs
#	src/app/bond/payout.rs
#	src/app/bond/slash.rs
#	src/main.rs
@grunch grunch marked this pull request as draft July 1, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants