diff --git a/Cargo.lock b/Cargo.lock index 7e26631f..ed789f1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,9 +1767,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76f4936f520e410ba2abf47113548ae231322209261a9b3a8aefbd10a869082" +checksum = "2f73dc932127909d84e64a3dd1a5cd0e9b6549fdef3eb6ec02a1de26a71472f9" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7b2d024f..3ecb6b4c 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.10.0", features = ["sqlx"] } +mostro-core = { version = "0.11.0", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } clap = { version = "4.5.45", features = ["derive"] } diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index 387c74b5..f96afccf 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -60,16 +60,23 @@ change. under [NIP-69](https://nips.nostr.com/69)'s four-bucket model (`pending` / `canceled` / `in-progress` / `success`). A malicious or absent taker cannot indefinitely hold an order off the book by - initiating a take and then never paying the bond. The - `supersede_prior_taker_bonds` mechanism (Phase 1) cancels stale - `Requested` bonds when a fresh take arrives; the **first bond to - reach `Locked` wins**, and only then does the order transition to - a trade-flow status (`WaitingPayment` / `WaitingBuyerInvoice`, - which map to NIP-69 `in-progress`). Any internal status the daemon - adds to distinguish "matched, awaiting bond" from "advertised, no - taker yet" (e.g. `Status::WaitingTakerBond` in §6.5) must map to - NIP-69 `pending` in `nip33::create_status_tags`, so external - observers still see an available order. + initiating a take and then never paying the bond. **Multiple + `Requested` bonds may coexist on a single order** — each fresh + take creates a new bond row alongside any prior `Requested` + rows, and the **first bond to reach `Locked` wins**. At the + moment of the winning lock, every other `Requested` bond on the + order is cancelled (its hold invoice is released and the prior + taker is notified with `Action::Canceled`) and only then does + the order transition to a trade-flow status (`WaitingPayment` / + `WaitingBuyerInvoice`, which map to NIP-69 `in-progress`). A + malicious taker who never pays does not block anyone: their + bond invoice expires on the LND-side timeout, and any + concurrent taker can still race them by paying their own bond. + Any internal status the daemon adds to distinguish "matched, + awaiting bond" from "advertised, no taker yet" (e.g. + `Status::WaitingTakerBond` in §6.5) must map to NIP-69 + `pending` in `nip33::create_status_tags`, so external observers + still see an available order. ## 3. Configuration surface (final shape) @@ -140,10 +147,13 @@ This feature touches two distinct axes that must not be conflated: - **Maker / taker — *who posted the bond, and when.*** The bond is requested at order-creation time (maker) or at take time (taker). `apply_to` is a maker/taker switch. `BondRole` in the data model is a - maker/taker enum. Phase 1's "supersede on retake" and Phase 5's - "publish-after-bond-locks" gating are genuinely maker/taker concerns - because they are about *order-flow* actions that only one role can - perform. + maker/taker enum. Phase 1's "concurrent taker bonds, first-to-lock + wins" semantics and Phase 5's "publish-after-bond-locks" gating are + genuinely maker/taker concerns because they are about *order-flow* + actions that only one role can perform. The maker bond is 1-to-1 + with the order (there is only ever one maker); the taker side + admits N concurrent `Requested` bond rows per order until one + locks. - **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. Timeout responsibility maps @@ -328,20 +338,29 @@ risk to users. maker-side, including pending-order maker cancels), `admin_settle_action`, `admin_cancel_action`, and `scheduler::job_cancel_orders`. Slashing hooks are intentionally absent and land in Phase 2+. -- `take_buy_action` / `take_sell_action` call - `bond::supersede_prior_taker_bonds` before persisting the new take. A - still-`Requested` prior bond is released (its hold invoice cancelled) - so a malicious taker can't keep an order in `Pending` by abandoning the - bond invoice — anyone may re-take and the first bond to lock wins. A - `Locked` prior bond is treated as committed and the new take is - rejected with `PendingOrderExists`. +- `take_buy_action` / `take_sell_action` originally shipped with + `bond::supersede_prior_taker_bonds`, which cancelled any prior + `Requested` bond at retake time. Phase 1.5 (§6.5) replaces that + with **concurrent taker bonds**: a fresh take creates a new bond + row alongside any prior `Requested` rows; the **first bond to + reach `Locked` wins** and the cancellation of the losers happens + at lock-time, not at retake-time. A `Locked` prior bond still + blocks new takes with `PendingOrderExists`. This implementation + note is preserved for historical context — once Phase 1.5 ships, + `supersede_prior_taker_bonds` is removed and the take handler's + only pre-persist check is "is any bond on this order already + `Locked`?". - `cancel_action` recognises a bonded taker as authorised to cancel a - still-`Pending` order: when `event.sender` matches the `pubkey` of an - active bond on the order, the cancel routes through the existing - `cancel_order_by_taker` flow (release the bond, clear the taker - fields, republish the order). This lets a taker who took the order but - no longer wants to proceed back out cleanly instead of getting - `IsNotYourOrder`. + still-pre-trade order: when `event.sender` matches the `pubkey` of an + active `Requested` bond on the order, the cancel routes through the + existing `cancel_order_by_taker` flow. Under Phase 1's + one-bond-at-a-time invariant this releases the sole bond and resets + the taker fields; under Phase 1.5's concurrent-bonds semantics it + releases **only the sender's own bond** (other concurrent takers' + bonds keep their HTLCs alive — they did not cancel) and the order + stays in `WaitingTakerBond` if other `Requested` bonds remain. This + lets a taker who took the order but no longer wants to proceed back + out cleanly instead of getting `IsNotYourOrder`. - On daemon startup, `bond::resubscribe_active_bonds` re-attaches LND invoice subscribers for any bond rows still in `Requested` / `Locked`, so a restart never strands a taker who paid the bond just before the @@ -404,18 +423,33 @@ request and the bond locking, the order's published NIP-33 status stays `pending` (per §2 principle 8). This is deliberate: a taker who never pays the bond bolt11 cannot park the order off the book. Other users browsing the order book continue to see it as available, and -any of them may attempt a fresh take. Whichever bond reaches `Locked` -first wins; prior `Requested` bonds are cancelled by -`supersede_prior_taker_bonds`, and the prior taker is notified with -`Action::Canceled`. +any of them may attempt a fresh take. + +Under Phase 1's original semantics, a fresh take cancelled prior +`Requested` bonds immediately via `supersede_prior_taker_bonds`, so +a slow taker received `Action::Canceled` as soon as anyone else +pressed "take". Phase 1.5 (§6.5) switches to **concurrent taker +bonds**: prior bonds stay alive — every concurrent taker keeps a +valid, payable bond invoice — and the first to actually pay (reach +`Locked`) wins. Only at that point are the losing concurrent +`Requested` bonds cancelled and their takers notified with +`Action::Canceled`. The TTL on each LND hold invoice still ensures +a malicious taker who never pays cannot block the order book +indefinitely. What this means for clients in practice: - A client that sent `take-buy` / `take-sell` and is waiting for `pay-bond-invoice` may receive `Action::Canceled` instead — meaning - someone else paid their bond first. Surface this clearly: "Order - was taken by another user before you locked the bond." Don't retry - the take silently; the order may not be available anymore. + another concurrent taker locked their bond first. Surface this + clearly: "Order was taken by another user before your bond was + paid." Don't retry the take silently; the order may not be + available anymore. +- Re-emitting `take-buy` / `take-sell` from the same pubkey while + the client's bond is still `Requested` is idempotent: the daemon + returns the same bolt11 instead of creating a second row for the + same taker. Treat duplicate `pay-bond-invoice` messages on the + same order as a re-send of the original, not a new bond. - Don't gray out / hide the order from the local order-book view just because the user initiated a take. Until `Locked`, the order is still genuinely available to everyone. @@ -453,8 +487,10 @@ Recommended client behaviour during the Phase 1 window: hold invoice (e.g. relay loss, daemon restart), the bond is released by `bond::resubscribe_active_bonds` on restart or by the scheduler timeout. Clients should treat the take as "stalled, will - resolve" rather than retrying take-buy/take-sell, which would race - with `supersede_prior_taker_bonds`. + resolve" rather than retrying take-buy/take-sell — under + Phase 1.5 a re-emit from the same pubkey returns the same bolt11 + (idempotent), under Phase 1 it would have raced with + `supersede_prior_taker_bonds` and reset the bond. These behaviours stay correct after Phase 1.5; the new action type just means method (1) becomes "match on `Action::PayBondInvoice`" @@ -513,9 +549,54 @@ never has to lean on memo parsing in the wild. - `take_buy_action` / `take_sell_action` must accept takes against orders in **either** `Pending` or `WaitingTakerBond` — both are pre-trade states from the take-validation perspective. - `supersede_prior_taker_bonds` continues to gate "is anyone - already locked?" the same way it does today, independent of the - order status column. + - **Switch from supersede to concurrent bonds.** Phase 1's + `bond::supersede_prior_taker_bonds` helper is removed. The take + handler's pre-persist check shrinks to: + 1. If `find_active_bonds_for_order` returns any bond in + `BondState::Locked`, reject the take with `PendingOrderExists` + (someone has already paid; the order is committed). + 2. If the sender's own pubkey already has a `Requested` bond on + this order, return the existing `payment_request` instead of + creating a new row — idempotent retry. + 3. Otherwise, create a fresh `Requested` bond row alongside any + prior `Requested` rows from *other* pubkeys. The other rows + are **not** touched. + The take handler must also stop persisting taker-flow fields + (`buyer_pubkey` / `seller_pubkey`, `master_*_pubkey`, + `buyer_invoice`, `trade_index_*`, range-order `fiat_amount` / + `amount` / `fee` / `dev_fee`) directly to the `orders` row + while the order is in `WaitingTakerBond`. Those fields go on + the bond row instead (new columns: `taker_invoice`, + `taker_trade_index`, `taker_identity`, `taker_fiat_amount`, + `taker_amount`, `taker_fee`, `taker_dev_fee`). They are copied + into the `orders` row by `resume_take_after_bond` at the moment + the winning bond locks, so the order has no "ghost" taker + while N concurrent bonds are racing. + - **First-to-lock-wins resolution.** `on_bond_invoice_accepted` + becomes the cancel-the-losers chokepoint. The `Requested → Locked` + UPDATE gains a `NOT EXISTS (SELECT 1 FROM bonds WHERE order_id = ? + AND state = 'Locked' AND id != ?)` guard so exactly one bond + can win per order — if two `Accepted` events arrive in the same + window, the losing UPDATE returns `rows_affected = 0` and the + handler cancels its own HTLC with `cancel_hold_invoice` (the + hold invoice is still cancelable: Mostro has not released the + preimage yet) and notifies its taker with `Action::Canceled`. + Once a bond does win, the handler iterates every other still- + `Requested` bond on the order, calls `release_bond` on each + (LND hold-invoice cancel + `BondState::Released`), and DMs + each loser an `Action::Canceled`. Only after this cleanup does + it copy the winning bond's `taker_*` context onto the order + and call `resume_take_after_bond`. + - **Migration (additive).** New migration + `migrations/_bond_taker_context.sql` adds the columns above + to the `bonds` table. All nullable, no backfill needed (Phase 0 + bonds will not have take context; Phase 1.5 bonds always will). + Refresh `sqlx-data.json`. + - **New DB helper.** `find_active_bond_by_taker(pool, order_id, + taker_pubkey) -> Option` filtering on `state IN + ('Requested', 'Locked')` and `pubkey = ?`. Used by the + idempotent retry check above and by `cancel_action` (next + bullet). - `cancel_action` must treat `WaitingTakerBond` as an alias of `Pending` for routing decisions. The bond is outstanding but the trade flow has **not** started, so the cooperative-cancel logic @@ -526,16 +607,23 @@ never has to lean on memo parsing in the wild. WaitingTakerBond }`. Inside the branch the existing two-route logic stays: - **Maker self-cancel.** `cancel_pending_order_from_maker` runs - and the order publishes as `Status::Canceled`. Any active - bond row on the order (the prospective taker's bond, still in - `Requested`) is released as part of this — the release hook - is already wired into the maker-cancel path in Phase 1; only - the status guard needs to widen. - - **Taker self-cancel.** `cancel_order_by_taker` releases the - taker's bond, clears the taker fields, and re-publishes the - order. External observers see no change in NIP-33 status (it - was `pending` throughout per §2 principle 8); the prospective - taker is gone and someone else may take. + and the order publishes as `Status::Canceled`. **Every** active + bond row on the order (all concurrent prospective takers) is + released as part of this — the release hook is already wired + into the maker-cancel path in Phase 1; only the status guard + needs to widen. + - **Taker self-cancel.** `cancel_order_by_taker` releases + **only the sender's own bond** (looked up with + `find_active_bond_by_taker`). If other prospective takers + still have `Requested` bonds on the order, the order stays + in `WaitingTakerBond` and is re-published. If the cancelling + taker was the last one, the order drops back to `Pending`. + External observers see no change in NIP-33 status either way + (it was `pending` throughout per §2 principle 8). Since no + taker fields are persisted on the order during + `WaitingTakerBond` (per the concurrent-bonds rework above), + there are no "taker fields to clear" — the cancel only + releases the bond and republishes status. Without this widening the daemon falls through to the default `_ => NotAllowedByStatus` arm and rejects every cancel during the bond window — a regression vs. Phase 1, where the same @@ -545,10 +633,15 @@ never has to lean on memo parsing in the wild. - On bond `Locked`, transition `WaitingTakerBond` → `WaitingPayment` / `WaitingBuyerInvoice` as the existing trade flow does (and republish NIP-33 with the real new status — at - that point the order is genuinely no longer takeable). - - On bond release before lock (taker abandons, supersede, - cancellation): transition `WaitingTakerBond` → `Pending` and - republish so any internal/external state stays consistent. + that point the order is genuinely no longer takeable). The + winning bond's `taker_*` columns are copied onto the order + row in the same DB transaction so the trade flow sees a + consistent snapshot. + - On bond release before lock (taker abandons, taker self-cancel, + or losing the lock race): if **no** other active bond remains on + the order, transition `WaitingTakerBond` → `Pending` and + republish. If other `Requested` bonds remain (a fresh concurrent + taker is still in flight), leave the order in `WaitingTakerBond`. - The trade hold invoice continues to ship as `Action::PayInvoice` — only the bond switches. - **Bump the `mostro-core` pin** in this repo's `Cargo.toml` from @@ -580,37 +673,84 @@ never has to lean on memo parsing in the wild. outstanding, flips out to `WaitingPayment` / `WaitingBuyerInvoice` (per the existing trade flow) once the bond locks, and falls back to `Pending` on bond release before lock. -- **Non-blockability test (load-bearing).** With order in status - `WaitingTakerBond`: +- **Non-blockability test (load-bearing) — concurrent bonds.** With + order in status `WaitingTakerBond` and bond A in `Requested`: - `nip33::create_status_tags` returns `(true, Status::Pending)` — i.e. the order publishes in NIP-69's `pending` bucket, identical to the wire output for an order in `Pending`. - A second `take-buy` / `take-sell` from a different pubkey is - accepted: `supersede_prior_taker_bonds` cancels the prior - `Requested` bond, the prior taker receives `Action::Canceled`, - the new bond is created, and the order's status remains - `WaitingTakerBond` (now reflecting the new taker). - - Repeating the cycle N times never causes the order's NIP-69 - bucket to leave `pending` until a bond actually `Locked`. + accepted: bond A **remains** in `Requested` (its hold invoice + is *not* cancelled), bond B is created in `Requested`, + `find_active_bonds_for_order` returns `[A, B]`, and bond A's + taker receives **no** `Action::Canceled` at this point. + - A third concurrent take from a third pubkey is also accepted, + producing bond C. `find_active_bonds_for_order` returns + `[A, B, C]`. The order's status remains `WaitingTakerBond` + throughout. + - Re-emitting `take-sell` from bond A's pubkey while A is still + `Requested` is idempotent: no new row is created, the same + `payment_request` is re-sent on `Action::PayBondInvoice`. + - When bond A's hash receives `InvoiceState::Accepted`: A + transitions to `Locked`; bonds B and C transition to + `Released` with their hold invoices cancelled via + `cancel_hold_invoice`; B's and C's takers each receive + `Action::Canceled`; A's `taker_*` columns are copied onto the + `orders` row; the order transitions to `WaitingPayment` / + `WaitingBuyerInvoice` as the existing trade flow does. + - The order's NIP-69 bucket never leaves `pending` until a + bond actually `Locked` (regardless of how many concurrent + takers come and go). +- **Locked-bond gate.** Once any bond on an order reaches `Locked`, + any subsequent `take-buy` / `take-sell` (from any pubkey, + including the locking taker's) is rejected with + `PendingOrderExists`. Concurrent `Requested` bonds are no longer + permitted at that point — the trade is committed. +- **Range order: per-bond pricing.** For a range order with + `price_from_api = true`, taker A takes at quote X. 30 seconds + later, taker B takes at quote Y (Y ≠ X because the rate moved). + Both bonds are `Requested`. When A's bond locks, the order's + `amount` / `fee` / `dev_fee` / `fiat_amount` are populated from + A's `taker_*` columns — i.e. quote X. Y is discarded along with + bond B. Confirms each bond carries its own pricing snapshot + rather than racing to mutate the `orders` row at take time. +- **Lock-race: two `Accepted` events in the same window.** With + bonds A and B both in `Requested`, fire `on_bond_invoice_accepted` + for both back-to-back. The conditional UPDATE's `NOT EXISTS` + guard ensures exactly one transition to `Locked` succeeds. The + loser's handler observes `rows_affected = 0`, calls + `cancel_hold_invoice` on its own hash to release its taker's + HTLC without settling, and DMs `Action::Canceled` to its taker. + Net effect is identical to the staggered case; no double-lock + and no settled HTLC for the loser. - Seller-as-taker case: one `PayBondInvoice` followed by one `PayInvoice` on the same order, both visible on the wire as distinct action types — no memo parsing needed by the client. -- **Cancel during `WaitingTakerBond` — taker self-cancel.** With the - order in `WaitingTakerBond` and the prospective taker's bond in - `Requested`, the taker sends a `cancel` action. The daemon - releases the bond, clears the taker fields, transitions the - order back to `Pending`, and republishes — `cancel_action` - returns `Ok(())` (not `NotAllowedByStatus`). The published - NIP-33 status was `pending` throughout, so external observers - see no transition; the order remains takeable. +- **Cancel during `WaitingTakerBond` — taker self-cancel, lone + taker.** With the order in `WaitingTakerBond` and exactly one + prospective taker's bond in `Requested`, the taker sends a + `cancel` action. The daemon releases that single bond, + transitions the order back to `Pending`, and republishes — + `cancel_action` returns `Ok(())` (not `NotAllowedByStatus`). + The published NIP-33 status was `pending` throughout, so + external observers see no transition; the order remains + takeable. +- **Cancel during `WaitingTakerBond` — taker self-cancel, others + still active.** With the order in `WaitingTakerBond` and bonds + A and B both in `Requested`, A's taker sends `cancel`. Bond A + is released; bond B remains in `Requested` and continues racing. + The order **stays** in `WaitingTakerBond` (does not drop to + `Pending` because B is still active). No `Action::Canceled` is + sent to B's taker. Confirms taker self-cancel is scoped to the + sender's own bond only. - **Cancel during `WaitingTakerBond` — maker self-cancel.** With the - order in `WaitingTakerBond` (a prospective taker is mid-bond), - the maker sends a `cancel` action. The daemon runs - `cancel_pending_order_from_maker`: the order transitions to - `Status::Canceled`, the prospective taker's `Requested` bond is - released, and the NIP-33 event republishes with - `s = canceled`. Confirms maker-side cancel is not blocked by a - pending bond. + order in `WaitingTakerBond` and one or more concurrent prospective + takers mid-bond, the maker sends a `cancel` action. The daemon + runs `cancel_pending_order_from_maker`: the order transitions to + `Status::Canceled`, **every** active prospective taker's + `Requested` bond is released, every prospective taker receives + `Action::Canceled`, and the NIP-33 event republishes with + `s = canceled`. Confirms maker-side cancel is not blocked by + pending bonds and fans out cancellation to all concurrent takers. - **Cancel during `WaitingTakerBond` — third-party rejected.** A pubkey that is neither the maker nor a bonded taker on the order receives `IsNotYourOrder`. Same rejection semantics as the @@ -629,7 +769,13 @@ never has to lean on memo parsing in the wild. unnecessary; clients dispatch on action type alone. - The §2 non-blockability invariant is preserved: orders in `WaitingTakerBond` map to NIP-69 `pending` and re-take from a - different pubkey works exactly as it does in Phase 1. + different pubkey is accepted. The mechanics change from Phase 1 + (immediate supersede of the prior `Requested` bond) to + concurrent bonds (the prior `Requested` bond stays alive; the + first to `Locked` wins and cancels the losers at lock-time). +- `bond::supersede_prior_taker_bonds` is removed from + `src/app/bond/flow.rs`. The take handler's only pre-persist + check is "is any bond on this order already `Locked`?". - Phase 2 can rely on the clean API when it ships. --- diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index df50a0f2..f60dfc16 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -1,27 +1,32 @@ -//! Bond lifecycle wiring (Phase 1). +//! Bond lifecycle wiring (Phase 1 + Phase 1.5). //! -//! Phase 1 adds a single guarantee: when the feature is enabled and the -//! taker side is in scope (`apply_to ∈ {take, both}`), a taker is asked to -//! lock a Lightning hold invoice as a bond before the trade flow starts; -//! and on **every** exit — happy path, unilateral cancel, cooperative -//! cancel, admin action, scheduler timeout — the bond is **released**. +//! Phase 1 added a single guarantee: when the feature is enabled and the +//! taker side is in scope (`apply_to ∈ {take, both}`), a taker is asked +//! to lock a Lightning hold invoice as a bond before the trade flow +//! starts; and on **every** exit — happy path, unilateral cancel, +//! cooperative cancel, admin action, scheduler timeout — the bond is +//! **released**. //! -//! Slashing is intentionally absent: it lands in Phase 2+. This means -//! operators can flip `enabled = true` in staging and exercise hold-invoice -//! custody end-to-end without any user funds at risk if Mostro mis-judges -//! the situation. +//! Slashing is intentionally absent: it lands in Phase 2+. Operators can +//! flip `enabled = true` and exercise hold-invoice custody end-to-end +//! without any user funds at risk if Mostro mis-judges the situation. //! -//! Protocol note: `mostro-core` 0.10.0 does not yet expose -//! `Action::PayBondInvoice` / `Status::WaitingTakerBond`. Phase 1 takes the -//! "Alternative" path documented in §6.2 of `docs/ANTI_ABUSE_BOND.md`: -//! orders stay in `Status::Pending` while waiting for the bond, and the -//! bond bolt11 ships to the taker as a regular `Action::PayInvoice` (the -//! semantics — "pay this Lightning invoice" — are an exact match). Bond -//! state lives entirely in the `bonds` table; clients identify the -//! invoice as a bond by its hash, which differs from the trade hold -//! invoice that follows once the bond is locked. The dedicated action / -//! status will land alongside the corresponding `mostro-core` release in a -//! later phase. +//! Phase 1.5 retired Phase 1's "alternative path" (which reused +//! `Action::PayInvoice` and kept the order in `Status::Pending`). The +//! bond bolt11 now ships as the dedicated `Action::PayBondInvoice` and +//! the order's status flips to `Status::WaitingTakerBond` while the +//! bond is outstanding. Both variants are introduced in `mostro-core` +//! 0.11.0; clients route bond invoices by action type instead of memo +//! parsing. +//! +//! Non-blockability invariant (`docs/ANTI_ABUSE_BOND.md` §2 principle 8): +//! the order's NIP-33 published status remains `pending` while internal +//! status is `WaitingTakerBond`. The mapping lives in +//! `nip33::create_status_tags`. Other potential takers continue to see +//! the order as available; whichever bond locks first wins, and +//! `supersede_prior_taker_bonds` below releases stale `Requested` +//! bonds so a malicious or absent taker cannot park the order +//! off-market. use std::str::FromStr; use std::sync::Arc; @@ -101,15 +106,15 @@ pub async fn request_taker_bond( bond.id, order.id, bond.role, bond.amount_sats ); - // Phase-1 alternative path (see module-level doc): the bond bolt11 - // ships as a regular `PayInvoice`. The `SmallOrder` echoes the order - // id so a bond-aware client can correlate — and a non-bond-aware - // client just sees an extra invoice to pay before the trade. + // The `SmallOrder` echo lets the client correlate this bond DM to + // the order. Status carries `WaitingTakerBond` so a bond-aware + // client can render the bond-payment phase distinctly from the + // trade hold invoice that may follow. let order_kind = order.get_order_kind().map_err(MostroInternalErr)?; let bond_small = SmallOrder::new( Some(order.id), Some(order_kind), - Some(Status::Pending), + Some(Status::WaitingTakerBond), amount, order.fiat_code.clone(), order.min_amount, @@ -149,7 +154,7 @@ pub async fn request_taker_bond( enqueue_order_msg( request_id, Some(order.id), - Action::PayInvoice, + Action::PayBondInvoice, Some(Payload::PaymentRequest( Some(bond_small), invoice_resp.payment_request, @@ -570,10 +575,15 @@ async fn on_bond_invoice_accepted( })?; // Defense-in-depth: only drive the take forward when the order is - // still in the pre-trade state we left it in. If it's already moved - // on (resume succeeded on a previous firing) or been canceled by a - // maker / admin / scheduler path, do not re-trigger the take. - if order.status != Status::Pending.to_string() { + // still in the pre-trade state we left it in. Both `Pending` and + // `WaitingTakerBond` are pre-trade (after Phase 1.5 the take + // handlers flip to `WaitingTakerBond`; resume transitions out of + // either back into the trade flow). If the order has moved on + // (resume already succeeded on a previous firing) or been canceled + // by a maker / admin / scheduler path, do not re-trigger the take. + if order.status != Status::Pending.to_string() + && order.status != Status::WaitingTakerBond.to_string() + { info!( "Bond {} accepted but order {} is in status {} — skipping resume", current.id, order.id, order.status @@ -588,11 +598,12 @@ async fn on_bond_invoice_accepted( /// Subscriber callback for `InvoiceState::Canceled`: bond never locked /// (taker abandoned the invoice, or LND auto-canceled on expiration). /// -/// Phase 1 keeps the order untouched: it stays `Pending` with the taker -/// fields populated. The maker's order remains discoverable via the -/// existing Nostr event. A follow-up phase (or operator action) can -/// reset the order if needed; for Phase 1, "always release" is the only -/// guarantee we owe. +/// Marks the bond as `Released`. If this was the only active bond on +/// the order and the order is still in `WaitingTakerBond`, transition +/// it back to `Pending` and republish so the daemon's internal view +/// matches reality. Taker fields (`seller_pubkey` / `buyer_invoice`) +/// are deliberately left stale: a new take overwrites them, and +/// "always release" is the only guarantee we owe on this path. async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), MostroError> { let bond = match find_bond_by_hash(pool, hash).await? { Some(b) => b, @@ -618,6 +629,77 @@ async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), "Bond {} marked Released after LND cancel (order {})", bond.id, bond.order_id ); + + // Phase 1.5 cleanup: if the order is in `WaitingTakerBond` and no + // other active bond remains (i.e. this was a lone taker who + // abandoned, not a supersede where a new bond is already in + // flight), flip back to `Pending`. Otherwise leave the order + // alone — `Canceled` (maker self-cancel ran), an active new bond + // (supersede), or any post-trade-flow status all mean someone + // else owns the order's status now. + let order_id = bond.order_id; + let bond_id = bond.id; + let order = match Order::by_id(pool, order_id).await { + Ok(Some(o)) => o, + Ok(None) => return Ok(()), + Err(e) => { + warn!( + bond_id = %bond_id, + order_id = %order_id, + "could not load order to reset status after bond cancel: {}", e + ); + return Ok(()); + } + }; + if order.status != Status::WaitingTakerBond.to_string() { + return Ok(()); + } + // Transient DB errors must NOT be coerced into "no active bonds": + // doing so would let a supersede race (where a fresh bond is + // already in flight on the same order) get clobbered back to + // `Pending`, violating the supersede guard. Log and bail + // instead — the order stays in `WaitingTakerBond`, the wire + // still publishes as `pending` (non-blockability invariant), and + // the next event on this order can re-run the cleanup. + let active = match find_active_bonds_for_order(pool, order_id).await { + Ok(v) => v, + Err(e) => { + warn!( + bond_id = %bond_id, + order_id = %order_id, + "could not check for other active bonds after release: {} — leaving order status alone", e + ); + return Ok(()); + } + }; + if !active.is_empty() { + return Ok(()); + } + + let my_keys = match get_keys() { + Ok(k) => k, + Err(e) => { + warn!( + order_id = %order_id, + "could not load Mostro keys to republish after bond cancel: {}", e + ); + return Ok(()); + } + }; + match crate::util::update_order_event(&my_keys, Status::Pending, &order).await { + Ok(order_updated) => { + if let Err(e) = order_updated.update(pool).await { + warn!( + order_id = %order_id, + "failed to persist Pending status after bond cancel: {}", e + ); + } + } + Err(e) => warn!( + order_id = %order_id, + "failed to republish Pending status after bond cancel: {}", e + ), + } Ok(()) } diff --git a/src/app/cancel.rs b/src/app/cancel.rs index 105ffba1..b2c3b51b 100644 --- a/src/app/cancel.rs +++ b/src/app/cancel.rs @@ -458,8 +458,16 @@ async fn cancel_action_generic( return Err(MostroCantDo(CantDoReason::OrderAlreadyCanceled)); } - // Pending: maker can revert to Canceled state and republish without cooperative steps. - if order.check_status(Status::Pending).is_ok() { + // Pre-trade (Pending or, since Phase 1.5, WaitingTakerBond): maker + // can revert to Canceled state and republish without cooperative + // steps; a bonded taker can self-cancel their take. The trade flow + // has not started in either status, so the `WaitingPayment` / + // `WaitingBuyerInvoice` cooperative-cancel path is NOT applicable + // here. Per `docs/ANTI_ABUSE_BOND.md` §6.5.1, both statuses are + // aliases for routing purposes inside this branch. + if order.check_status(Status::Pending).is_ok() + || order.check_status(Status::WaitingTakerBond).is_ok() + { if order.sent_from_maker(event.sender).is_ok() { cancel_pending_order_from_maker(pool, event, &mut order, my_keys, request_id).await?; return Ok(()); diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 6c4335d5..ccf80858 100644 --- a/src/app/take_buy.rs +++ b/src/app/take_buy.rs @@ -2,7 +2,8 @@ use crate::app::bond; use crate::app::bond::supersede_prior_taker_bonds; use crate::app::context::AppContext; use crate::util::{ - get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, get_order, show_hold_invoice, + get_dev_fee, get_fiat_amount_requested, get_market_amount_and_fee, get_order, + show_hold_invoice, update_order_event, }; use crate::db::{seller_has_pending_order, update_user_trade_index}; @@ -33,9 +34,16 @@ pub async fn take_buy_action( if let Err(cause) = order.is_buy_order() { return Err(MostroCantDo(cause)); }; - // Check if the order status is pending - if let Err(cause) = order.check_status(Status::Pending) { - return Err(MostroCantDo(cause)); + // Check if the order status is pre-trade. After Phase 1.5, + // `WaitingTakerBond` is the daemon-internal "matched, awaiting + // bond" state; it remains takeable on the wire (NIP-69 `pending`) + // per the non-blockability invariant, and `supersede_prior_taker_bonds` + // below rejects only when a prior bond is already `Locked`. Both + // statuses are pre-trade; either is a valid entry point for take. + if order.check_status(Status::Pending).is_err() + && order.check_status(Status::WaitingTakerBond).is_err() + { + return Err(MostroCantDo(CantDoReason::InvalidOrderStatus)); } // Validate that the order was sent from the correct maker @@ -43,19 +51,22 @@ pub async fn take_buy_action( .not_sent_from_maker(event.sender) .map_err(MostroCantDo)?; - // Anti-abuse bond (Phase 1): release any prior taker's still- - // `Requested` bond before this take proceeds, so a malicious user - // can't block the order by abandoning the bond invoice. A `Locked` - // prior bond means the trade is already committed and the helper - // returns `PendingOrderExists`. Done before the market-price - // recomputation below so re-takes of API-priced orders see a fresh - // quote. + // Anti-abuse bond reconciliation: always run the supersede pass + // before this take proceeds, regardless of the current + // `taker_bond_required()` flag. The reason is that bonds may + // have been enabled at the time a *prior* taker took this order + // — leaving a `Requested` or `Locked` bond row attached — and + // then disabled before the current take arrives. Gating on + // `taker_bond_required()` would silently skip that + // reconciliation: a `Locked` prior bond would no longer block + // the take (regression vs. the rule that locked bonds mean the + // trade is committed), and a `Requested` prior bond would be + // orphaned in the DB. The helper is a no-op (returns `Ok(0)`) + // when no active bond exists for the order, so always-calling + // is safe and cheap. Done before the market-price recomputation + // below so re-takes of API-priced orders see a fresh quote. let bond_required = bond::taker_bond_required(); - let superseded = if bond_required { - supersede_prior_taker_bonds(pool, order.id, event.sender).await? - } else { - 0 - }; + let superseded = supersede_prior_taker_bonds(pool, order.id, event.sender).await?; if superseded > 0 && order.price_from_api { order.amount = 0; order.fee = 0; @@ -114,18 +125,26 @@ pub async fn take_buy_action( .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - // Anti-abuse bond (Phase 1): if the operator opted into a taker bond, - // intercept the take here. We persist the partially-populated order - // (status stays `Pending`) and request the bond. The trade hold - // invoice is created later — once the bond locks — by the bond - // subscriber's continuation in `bond::flow::resume_take_after_bond`. + // Anti-abuse bond (Phase 1.5): if the operator opted into a taker + // bond, intercept the take here. The order's status flips to + // `WaitingTakerBond` while we wait for the taker to lock the bond + // hold invoice. The wire-published NIP-33 status stays `pending` + // per the non-blockability invariant (see + // `nip33::create_status_tags`); the trade hold invoice is created + // later — once the bond locks — by `bond::flow::resume_take_after_bond`. if bond_required { // Stash the seller (taker) trade pubkey so the post-bond // continuation can resume `show_hold_invoice` with the same // arguments the legacy path would have used. order.seller_pubkey = Some(seller_pubkey.to_string()); - let persisted = order + // Republish NIP-33 with `WaitingTakerBond` (which maps back + // to NIP-69 `pending` for the wire), then persist. + let order_updated = update_order_event(my_keys, Status::WaitingTakerBond, &order) + .await + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + let persisted = order_updated .update(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 9a566dea..a4f720d7 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -56,9 +56,16 @@ pub async fn take_sell_action( if let Err(cause) = order.is_sell_order() { return Err(MostroCantDo(cause)); }; - // Check if the order status is pending - if let Err(cause) = order.check_status(Status::Pending) { - return Err(MostroCantDo(cause)); + // Check if the order status is pre-trade. After Phase 1.5, + // `WaitingTakerBond` is the daemon-internal "matched, awaiting + // bond" state; it remains takeable on the wire (NIP-69 `pending`) + // per the non-blockability invariant, and `supersede_prior_taker_bonds` + // below rejects only when a prior bond is already `Locked`. Both + // statuses are pre-trade; either is a valid entry point for take. + if order.check_status(Status::Pending).is_err() + && order.check_status(Status::WaitingTakerBond).is_err() + { + return Err(MostroCantDo(CantDoReason::InvalidOrderStatus)); } // Validate that the order was sent from the correct maker @@ -66,17 +73,21 @@ pub async fn take_sell_action( .not_sent_from_maker(event.sender) .map_err(MostroCantDo)?; - // Anti-abuse bond (Phase 1): release any prior taker's still- - // `Requested` bond before this take proceeds. A `Locked` prior bond - // means the trade is already committed and the helper returns - // `PendingOrderExists`. Done before the market-price recomputation - // below so re-takes of API-priced orders see a fresh quote. + // Anti-abuse bond reconciliation: always run the supersede pass + // before this take proceeds, regardless of the current + // `taker_bond_required()` flag. Bonds may have been enabled at + // the time a *prior* taker took this order — leaving a + // `Requested` or `Locked` bond row attached — and then disabled + // before the current take arrives. Gating on + // `taker_bond_required()` would silently skip that + // reconciliation: a `Locked` prior bond would no longer block + // the take, and a `Requested` prior bond would be orphaned in + // the DB. The helper is a no-op (returns `Ok(0)`) when no + // active bond exists for the order, so always-calling is safe + // and cheap. Done before the market-price recomputation below + // so re-takes of API-priced orders see a fresh quote. let bond_required = bond::taker_bond_required(); - let superseded = if bond_required { - supersede_prior_taker_bonds(pool, order.id, event.sender).await? - } else { - 0 - }; + let superseded = supersede_prior_taker_bonds(pool, order.id, event.sender).await?; if superseded > 0 && order.price_from_api { order.amount = 0; order.fee = 0; @@ -141,12 +152,13 @@ pub async fn take_sell_action( .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - // Anti-abuse bond (Phase 1): when enabled for the taker side, defer - // the trade hold-invoice / `WaitingBuyerInvoice` step. We persist the - // populated order (status stays `Pending`), stash the buyer payout - // invoice if the taker provided one, and request the taker's bond. - // `bond::flow::resume_take_after_bond` resumes the trade once the - // bond locks. + // Anti-abuse bond (Phase 1.5): when enabled for the taker side, + // defer the trade hold-invoice / `WaitingBuyerInvoice` step. The + // order's status flips to `WaitingTakerBond` while we wait for the + // taker to lock the bond hold invoice. The wire-published NIP-33 + // status stays `pending` per the non-blockability invariant (see + // `nip33::create_status_tags`); `bond::flow::resume_take_after_bond` + // resumes the trade once the bond locks. if bond_required { // Always set `buyer_invoice` from this take's `payment_request` // (including back to `None`): otherwise a prior taker's invoice @@ -154,7 +166,13 @@ pub async fn take_sell_action( // provide one. order.buyer_invoice = payment_request.clone(); - let persisted = order + // Republish NIP-33 with `WaitingTakerBond` (which maps back + // to NIP-69 `pending` for the wire), then persist. + let order_updated = update_order_event(my_keys, Status::WaitingTakerBond, &order) + .await + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + let persisted = order_updated .update(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; diff --git a/src/db.rs b/src/db.rs index 92fb95bb..98410db1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -586,11 +586,21 @@ pub async fn find_order_by_hash(pool: &SqlitePool, hash: &str) -> Result Result, MostroError> { let expire_time = Timestamp::now(); + // `waiting-taker-bond` is included so the pending-expiry path + // also catches orders that were matched to a taker who never + // locked their anti-abuse bond. Both statuses publish as NIP-69 + // `pending` on the wire (see `nip33::create_status_tags`); an + // order whose `expires_at` has passed must be expired regardless + // of which internal pre-trade state it sits in, otherwise it + // would linger publicly on the order book past its own deadline + // and the Phase 1 "always release on every exit path" guarantee + // for bonds would not run on the expiry path. let order = sqlx::query_as::<_, Order>( r#" SELECT * FROM orders - WHERE expires_at < ?1 AND status == 'pending' + WHERE expires_at < ?1 + AND status IN ('pending', 'waiting-taker-bond') "#, ) .bind(expire_time.as_secs() as i64) @@ -1516,6 +1526,69 @@ mod tests { ); } + // -- Tests for find_order_by_date (pending-expiry path) -- + + /// `insert_test_order` already sets `expires_at` to a 2023 timestamp, + /// so any order it creates is past `Timestamp::now()` and qualifies + /// as expired. + #[tokio::test] + async fn find_order_by_date_includes_pending_and_waiting_taker_bond() { + let pool = setup_orders_db().await.unwrap(); + let pending_id = uuid::Uuid::new_v4(); + let bond_id = uuid::Uuid::new_v4(); + + insert_test_order(&pool, pending_id, "pending", 0, false, None).await; + insert_test_order(&pool, bond_id, "waiting-taker-bond", 0, false, None).await; + + let result = super::find_order_by_date(&pool).await.unwrap(); + let ids: std::collections::HashSet<_> = result.iter().map(|o| o.id).collect(); + assert!( + ids.contains(&pending_id), + "expired pending order must be returned" + ); + assert!( + ids.contains(&bond_id), + "expired waiting-taker-bond order must be returned — same NIP-69 \ + bucket as pending, must hit the pending-expiry path so the order \ + cannot linger publicly past expires_at while a taker stalls on the bond" + ); + } + + #[tokio::test] + async fn find_order_by_date_excludes_post_trade_states() { + let pool = setup_orders_db().await.unwrap(); + + // Post-trade-flow statuses: not eligible for the pending-expiry + // path (they have their own lifecycle / scheduler hooks). + insert_test_order(&pool, uuid::Uuid::new_v4(), "active", 0, false, None).await; + insert_test_order( + &pool, + uuid::Uuid::new_v4(), + "waiting-payment", + 0, + false, + None, + ) + .await; + insert_test_order( + &pool, + uuid::Uuid::new_v4(), + "waiting-buyer-invoice", + 0, + false, + None, + ) + .await; + insert_test_order(&pool, uuid::Uuid::new_v4(), "success", 0, false, None).await; + insert_test_order(&pool, uuid::Uuid::new_v4(), "canceled", 0, false, None).await; + + let result = super::find_order_by_date(&pool).await.unwrap(); + assert!( + result.is_empty(), + "find_order_by_date must only return pending / waiting-taker-bond" + ); + } + // -- Tests for find_held_invoices -- #[tokio::test] diff --git a/src/nip33.rs b/src/nip33.rs index 45c0649f..dcf21cf5 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -213,7 +213,15 @@ fn create_rating_tag(reputation_data: Option<(f64, i64, i64)>) -> String { } fn create_fiat_amt_array(order: &Order) -> Vec { - if order.status == Status::Pending.to_string() { + // `WaitingTakerBond` publishes as NIP-69 `pending` per + // `create_status_tags` and the non-blockability invariant + // (`docs/ANTI_ABUSE_BOND.md` §2 principle 8), so it must emit the + // same fiat-amount tag a `Pending` order would — otherwise an + // observer who is browsing the order book to re-take during the + // bond window would see a degraded view. + if order.status == Status::Pending.to_string() + || order.status == Status::WaitingTakerBond.to_string() + { match (order.min_amount, order.max_amount) { (Some(min), Some(max)) => { vec![min.to_string(), max.to_string()] @@ -254,6 +262,17 @@ fn create_status_tags(order: &Order) -> Result<(bool, Status), MostroError> { | Status::Expired => Ok((true, Status::Canceled)), Status::Success | Status::CompletedByAdmin => Ok((true, status)), Status::Pending => Ok((true, status)), + // Anti-abuse bond non-blockability invariant + // (`docs/ANTI_ABUSE_BOND.md` §2 principle 8): a taker has been + // matched on this order and is in the middle of paying their + // bond. Internally the row is `WaitingTakerBond`, but on the + // wire it MUST keep advertising as NIP-69 `pending` so other + // takers continue to see the order in the public book and a + // malicious / absent taker cannot park it off-market by + // initiating a take and never paying. The first bond to lock + // wins; only at that point does the order publish a + // post-trade-flow status. + Status::WaitingTakerBond => Ok((true, Status::Pending)), _ => Ok((false, status)), } } @@ -291,7 +310,13 @@ fn create_source_tag( mostro_relays: &[String], mostro_pubkey: &str, ) -> Result, MostroError> { - if order.status == Status::Pending.to_string() { + // Mirror `create_fiat_amt_array`: a `WaitingTakerBond` order + // publishes as NIP-69 `pending` and must remain discoverable to + // other potential takers (re-takeability invariant); without the + // source tag, clients cannot reach back to the original event. + if order.status == Status::Pending.to_string() + || order.status == Status::WaitingTakerBond.to_string() + { // Create a mostro: custom source reference for pending orders // Include the Mostro pubkey so clients can identify the instance let custom_ref = format!( @@ -730,6 +755,72 @@ mod tests { ); } + // ── Non-blockability invariant (anti-abuse bond Phase 1.5) ───────────────── + // + // `docs/ANTI_ABUSE_BOND.md` §2 principle 8: an order with a taker + // mid-bond is internally `WaitingTakerBond` but MUST publish on the + // wire as NIP-69 `pending`. Without this, an attacker could park + // the order off the public book by initiating a take and never + // paying the bond. + + /// Extract the value of the "s" (status) tag from a Tags collection. + fn get_status_tag_value(tags: &Tags) -> Option { + tags.iter().find_map(|tag| { + let vec = tag.clone().to_vec(); + if vec.first().map(|s| s.as_str()) == Some("s") { + vec.get(1).cloned() + } else { + None + } + }) + } + + /// Build an order in the daemon-internal `WaitingTakerBond` status. + fn make_waiting_taker_bond_order() -> Order { + Order { + status: Status::WaitingTakerBond.to_string(), + kind: mostro_core::order::Kind::Sell.to_string(), + fiat_code: "USD".to_string(), + payment_method: "bank".to_string(), + ..Default::default() + } + } + + #[test] + fn waiting_taker_bond_publishes_as_pending_on_the_wire() { + init_test_settings(); + let order = make_waiting_taker_bond_order(); + + let tags = order_to_tags(&order, None, Some(TEST_MOSTRO_PUBKEY)) + .expect("order_to_tags must not error") + .expect("WaitingTakerBond order must publish (Some tags)"); + + let s = get_status_tag_value(&tags).expect("WaitingTakerBond order must emit an `s` tag"); + assert_eq!( + s, "pending", + "non-blockability invariant: a WaitingTakerBond order MUST \ + advertise as `pending` on the wire so other potential takers \ + can still see it on the order book" + ); + } + + #[test] + fn waiting_taker_bond_keeps_source_tag_for_re_takeability() { + init_test_settings(); + let order = make_waiting_taker_bond_order(); + + let tags = order_to_tags(&order, None, Some(TEST_MOSTRO_PUBKEY)) + .expect("order_to_tags must not error") + .expect("WaitingTakerBond order must publish (Some tags)"); + + // If the source tag is missing, clients cannot find the order + // event to re-take it during the bond window. + get_source_tag_value(&tags).expect( + "WaitingTakerBond order must keep its source tag so other takers \ + can still discover and re-take it", + ); + } + // ── info_to_tags: end-to-end y-tag emission (kind 38385) ──────────────────── #[test] diff --git a/src/util.rs b/src/util.rs index 1982657d..9a11821f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -679,7 +679,12 @@ async fn get_ratings_for_pending_order( order_updated: &Order, status: Status, ) -> Result, MostroError> { - if status == Status::Pending { + // `WaitingTakerBond` is the daemon-internal "matched, awaiting + // bond" state, but on the wire it publishes as NIP-69 `pending` + // (non-blockability invariant). Attach the rating tag the same + // way as for `Pending` so a re-take during the bond window has + // the same reputation context. + if status == Status::Pending || status == Status::WaitingTakerBond { let identity_pubkey = match order_updated.is_sell_order() { Ok(_) => order_updated .get_master_seller_pubkey()