Skip to content

Commit ad1f74a

Browse files
grunchclaude
andcommitted
feat(bond): publish bond policy snapshot on kind-38385; drop hardcoded payout-deadline message
Move the bond-payout forfeit deadline out of the daemon's wire payload and onto the info event, so clients can render it in the user's locale. `Action::AddBondInvoice` now carries only the structured request — the hardcoded English "claim by <ISO> or your share will be forfeited" DM is gone. `info_to_tags` now emits the bond-policy snapshot the spec §13.1 designated for Phase 8, brought forward to Phase 3 because the payout flow is what makes them load-bearing: - bond_enabled (always, including when disabled — disambiguates "feature off" from "older daemon that doesn't speak bond") - bond_apply_to, bond_slash_on_waiting_timeout - bond_amount_pct, bond_base_amount_sats - bond_slash_node_share_pct - bond_payout_claim_window_days (clients add this to slashed_at to render the forfeit deadline themselves) All emitted under snake_case to match the rest of `info_to_tags`. The six policy tags are present iff `bond_enabled = true`. Also: rename all comment/prose references to "DM" → "message" across the bond code and docs (and a handful of unrelated call sites) — the wire is generic Nostr messages, not direct messages specifically. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent ebeaf5f commit ad1f74a

12 files changed

Lines changed: 208 additions & 119 deletions

File tree

docs/ANTI_ABUSE_BOND.md

Lines changed: 71 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -260,20 +260,20 @@ Purely additive. Touches no trade flow.
260260
-- Phase 3: counts ONLY `send_payment` retries against an invoice
261261
-- the counterparty has already submitted. Bumped only by step 6
262262
-- of the §8.1 scheduler loop. `payout_max_retries` is checked
263-
-- against this counter alone — invoice-request DMs do NOT count
264-
-- here (see `invoice_request_attempts` below).
263+
-- against this counter alone — invoice-request messages do NOT
264+
-- count here (see `invoice_request_attempts` below).
265265
payout_attempts integer not null default 0,
266-
-- Phase 3: counts how many `Action::AddInvoice` DMs the scheduler
267-
-- has sent asking the counterparty for a payout invoice. Bumped
268-
-- by step 1 of §8.1. Bounded by the forfeit window
266+
-- Phase 3: counts how many `Action::AddInvoice` messages the
267+
-- scheduler has sent asking the counterparty for a payout invoice.
268+
-- Bumped by step 1 of §8.1. Bounded by the forfeit window
269269
-- (`payout_claim_window_days`), not by `payout_max_retries`, so
270270
-- a slow-responding counterparty cannot prematurely flip the
271271
-- bond to `Failed`.
272272
invoice_request_attempts integer not null default 0,
273-
-- Phase 3: timestamp of the last `AddInvoice` DM. Drives the
274-
-- `payout_invoice_window_seconds` cadence check ("don't re-DM
273+
-- Phase 3: timestamp of the last `AddInvoice` message. Drives the
274+
-- `payout_invoice_window_seconds` cadence check ("don't re-send
275275
-- before the window has elapsed"). Persisted so a daemon restart
276-
-- doesn't trigger an immediate re-DM.
276+
-- doesn't trigger an immediate re-send.
277277
last_invoice_request_at integer,
278278
locked_at integer,
279279
released_at integer,
@@ -525,7 +525,7 @@ changes — pure ergonomics.
525525
Lands **before Phase 2 on purpose**. Phase 2 introduces dispute
526526
slashes and the `BondResolution` payload; once that ships and
527527
operators flip `enabled = true` in production, every taker on every
528-
bond-enabled node sees the bond DM. We want clients to have already
528+
bond-enabled node sees the bond message. We want clients to have already
529529
adopted the clean API by then so the seller-as-taker case (§6.3)
530530
never has to lean on memo parsing in the wild.
531531

@@ -554,7 +554,7 @@ never has to lean on memo parsing in the wild.
554554
- **`mostrod` changes** in `src/app/bond/flow.rs::request_taker_bond`
555555
and the take handlers:
556556
- Replace `Action::PayInvoice` with `Action::PayBondInvoice` when
557-
enqueuing the bond DM.
557+
enqueuing the bond message.
558558
- Set the order's status to `Status::WaitingTakerBond` while the
559559
bond is outstanding (instead of leaving it in `Pending`). Add
560560
the NIP-69 mapping in `nip33::create_status_tags`:
@@ -602,7 +602,7 @@ never has to lean on memo parsing in the wild.
602602
preimage yet) and notifies its taker with `Action::Canceled`.
603603
Once a bond does win, the handler iterates every other still-
604604
`Requested` bond on the order, calls `release_bond` on each
605-
(LND hold-invoice cancel + `BondState::Released`), and DMs
605+
(LND hold-invoice cancel + `BondState::Released`), and messages
606606
each loser an `Action::Canceled`. Only after this cleanup does
607607
it copy the winning bond's `taker_*` context onto the order
608608
and call `resume_take_after_bond`.
@@ -670,7 +670,7 @@ never has to lean on memo parsing in the wild.
670670
### 6.5.2 Client compatibility
671671

672672
- A client that only knows `Action::PayInvoice` will silently ignore
673-
the bond DM after this phase ships, the bond will never lock, and
673+
the bond message after this phase ships, the bond will never lock, and
674674
the take will time out and release. No funds at risk, but the take
675675
fails. This is the expected behaviour for unknown-action handling
676676
per §14.2 ("Clients must handle unknown statuses gracefully") — it
@@ -687,7 +687,7 @@ never has to lean on memo parsing in the wild.
687687

688688
### 6.5.3 Tests
689689

690-
- Bond DM enqueues with `Action::PayBondInvoice`, not `PayInvoice`.
690+
- Bond message enqueues with `Action::PayBondInvoice`, not `PayInvoice`.
691691
- Order's **DB** status flips to `WaitingTakerBond` while the bond is
692692
outstanding, flips out to `WaitingPayment` /
693693
`WaitingBuyerInvoice` (per the existing trade flow) once the bond
@@ -738,7 +738,7 @@ never has to lean on memo parsing in the wild.
738738
guard ensures exactly one transition to `Locked` succeeds. The
739739
loser's handler observes `rows_affected = 0`, calls
740740
`cancel_hold_invoice` on its own hash to release its taker's
741-
HTLC without settling, and DMs `Action::Canceled` to its taker.
741+
HTLC without settling, and messages `Action::Canceled` to its taker.
742742
Net effect is identical to the staggered case; no double-lock
743743
and no settled HTLC for the loser.
744744
- Seller-as-taker case: one `PayBondInvoice` followed by one
@@ -986,30 +986,35 @@ trade finalization must never wait on the payout.
986986
bolt11): `settle_hold_invoice(preimage)`, transition the bond
987987
to `state = Forfeited`, publish the audit event with
988988
`outcome = forfeited`, and stop. The node retains the full
989-
`amount_sats`. No further DMs or `send_payment` runs.
989+
`amount_sats`. No further messages or `send_payment` runs.
990990
- Otherwise, proceed with the normal payout steps below.
991991
- Normal payout steps:
992992
1. If no `payout_invoice` yet, and the cadence window has elapsed
993993
(i.e. `last_invoice_request_at IS NULL` or
994994
`now - last_invoice_request_at >= payout_invoice_window_seconds`):
995-
enqueue an `Action::AddBondInvoice` DM (mostro-core 0.11.2+)
995+
enqueue an `Action::AddBondInvoice` message (mostro-core 0.11.2+)
996996
to the recipient (see "Recipient resolution" below) asking for
997997
a bolt11 for the full `counterparty_share_sats` — the handler
998998
validates the invoice principal against `counterparty_share_sats`
999999
with fee = 0, so the routing fee must come out of Mostro's
10001000
wallet, not the invoice principal. (The bonded user may use
10011001
the estimated routing fee as guidance when choosing a recipient
10021002
node, but it is not subtracted from the requested amount.)
1003-
The DM **must include the forfeit deadline** (e.g. "claim by
1004-
<ISO timestamp> or your share will be forfeited") via a
1005-
`Payload::TextMessage` body alongside the action, so the user
1006-
knows the clock is running. Bump `invoice_request_attempts`
1007-
and set `last_invoice_request_at = now`. **Do not touch
1008-
`payout_attempts`** — that counter only governs `send_payment`
1009-
retries (step 6); mixing the two would let a slow-responding
1010-
counterparty exhaust `payout_max_retries` on invoice-request
1011-
DMs alone and prematurely flip the bond to `Failed`. Bounding
1012-
invoice requests is the forfeit window's job
1003+
The message carries **only the structured request payload** — no
1004+
hardcoded human-readable text. The forfeit deadline is not
1005+
shipped inline: the client computes it locally from the slash
1006+
moment (observable on the order's audit trail) plus
1007+
`bond_payout_claim_window_days`, which Mostro advertises on the
1008+
kind-38385 info event (§13.1). Keeping the wire payload
1009+
text-free lets clients render the warning ("your share will be
1010+
forfeited in N days") in the user's own locale, and avoids
1011+
baking English copy into the daemon. Bump
1012+
`invoice_request_attempts` and set `last_invoice_request_at =
1013+
now`. **Do not touch `payout_attempts`** — that counter only
1014+
governs `send_payment` retries (step 6); mixing the two would
1015+
let a slow-responding counterparty exhaust `payout_max_retries`
1016+
on invoice-request messages alone and prematurely flip the bond to
1017+
`Failed`. Bounding invoice requests is the forfeit window's job
10131018
(`payout_claim_window_days`), not the retry budget's. The node
10141019
share never leaves Mostro's wallet, so it has no separate
10151020
invoice step.
@@ -1034,7 +1039,7 @@ trade finalization must never wait on the payout.
10341039
`Forfeited` (the user never gave us an invoice).
10351040

10361041
When `slash_node_share_pct = 1.0` the counterparty leg is skipped
1037-
entirely (no `AddBondInvoice` DM, no `send_payment`, no forfeit
1042+
entirely (no `AddBondInvoice` message, no `send_payment`, no forfeit
10381043
window to wait for); the bond goes straight from `PendingPayout`
10391044
settle → `Slashed` after step 3.
10401045

@@ -1060,7 +1065,7 @@ trade finalization must never wait on the payout.
10601065
taker have active bonds, neither party deserves restitution
10611066
(§15.2 — "both behaved badly"). For each row, treat as
10621067
`slash_node_share_pct = 1.0` for that payout: skip the
1063-
`AddBondInvoice` DM, retain `amount_sats` in full, settle the
1068+
`AddBondInvoice` message, retain `amount_sats` in full, settle the
10641069
HTLC in step 3, transition to `Slashed`. (Phase 5+ wires this;
10651070
Phase 2's taker-only world cannot reach this branch.)
10661071

@@ -1101,7 +1106,7 @@ trade finalization must never wait on the payout.
11011106
driving from that point on is the counterparty payout.
11021107
- **Partial success: settle OK, counterparty `send_payment` failed.**
11031108
The bond state stays in `PendingPayout` with a best-effort retry. The
1104-
winner is kept informed via periodic DMs. If retries exhaust, state
1109+
winner is kept informed via periodic messages. If retries exhaust, state
11051110
becomes `Failed`; at that point Mostro is holding the counterparty
11061111
share (unavoidable with the HTLC already settled) and logs loudly.
11071112
The node share is unaffected — it was always going to stay. This is
@@ -1113,7 +1118,7 @@ trade finalization must never wait on the payout.
11131118
the scheduler settles the HTLC and transitions to `Forfeited`. No
11141119
manual operator action is needed — this is a normal terminal
11151120
state, designed-in. Default 15 days gives even users with sporadic
1116-
Nostr presence ample time to see the DM and respond.
1121+
Nostr presence ample time to see the message and respond.
11171122
- **Non-blocking:** `release_action`, `admin_settle_action`, etc. return
11181123
success the moment the trade escrow resolves. Bond payout happens
11191124
later.
@@ -1128,7 +1133,7 @@ trade finalization must never wait on the payout.
11281133
- Edge case `slash_node_share_pct = 0.0` → behaviour identical to the
11291134
pre-split design (full counterparty payout).
11301135
- Edge case `slash_node_share_pct = 1.0` → settle the HTLC, no
1131-
`AddInvoice` DM is enqueued, no `send_payment` runs, bond goes
1136+
`AddInvoice` message is enqueued, no `send_payment` runs, bond goes
11321137
straight to `Slashed`.
11331138
- **Persistence test**: a bond enters `PendingPayout` under
11341139
`slash_node_share_pct = 0.5`; before payout completes, simulate a
@@ -1343,7 +1348,7 @@ it. The workable strategies:
13431348
visible.
13441349

13451350
We recommend **(a)**. Acceptable cost: maker sees one extra `add-invoice`
1346-
DM at range-close if there were partial slashes.
1351+
message at range-close if there were partial slashes.
13471352

13481353
The split is applied **per child** (each child's `slashed_share_sats`
13491354
is split independently), so the audit event for each child carries its
@@ -1405,20 +1410,40 @@ Tests mirror Phase 4 from the maker side; the "no slash" rows in the
14051410

14061411
### 13.1 Scope
14071412

1408-
- Extend the Mostro info event (`src/nip33.rs::info_to_tags`) with the
1409-
bond config snapshot so clients can show users what the node enforces
1410-
before they trade:
1411-
- `bond` (`enabled` | `disabled`)
1412-
- `bond-apply-to` (`take` | `create` | `both`)
1413-
- `bond-slash-timeout` (`true` | `false`)
1414-
- `bond-amount-pct` / `bond-amount-floor`
1415-
- `bond-slash-node-share-pct` (the operator's `slash_node_share_pct`,
1416-
so users know up-front how a slashed bond is split between the node
1417-
and the winning counterparty).
1418-
- `bond-payout-claim-window-days` (the operator's
1419-
`payout_claim_window_days`, so users know how long they have to
1420-
submit a payout invoice before forfeiting their share).
1421-
- **No `bond-slash-dispute` tag**: dispute slashes are solver-driven
1413+
- The Mostro info event (`src/nip33.rs::info_to_tags`) carries the
1414+
bond config snapshot so clients can show users what the node
1415+
enforces before they trade. **The full set below is shipped in
1416+
Phase 3** alongside the payout flow itself — the
1417+
`Action::AddBondInvoice` message intentionally carries no
1418+
human-readable deadline text, so the wire payload alone is not
1419+
enough for a client to warn the user; the kind-38385 tags close
1420+
that gap and let every warning render in the user's locale. Tag
1421+
naming follows the snake_case convention used elsewhere in
1422+
`info_to_tags` (`mostro_version`, `hold_invoice_expiration_window`,
1423+
etc.). The set:
1424+
- `bond_enabled` (`true` | `false`) — **always emitted**, including
1425+
on nodes where `[anti_abuse_bond]` is absent or `enabled =
1426+
false`. Disambiguates "feature off on this node" from "older
1427+
daemon that doesn't speak bond at all": the latter omits the tag
1428+
entirely, the former emits `false`. All remaining bond tags are
1429+
emitted only when this is `true`.
1430+
- `bond_apply_to` (`take` | `create` | `both`) — whether the user
1431+
needs to lock a bond as maker, taker, or both.
1432+
- `bond_slash_on_waiting_timeout` (`true` | `false`) — node policy:
1433+
can a bond be slashed for missing a waiting-state timeout, or
1434+
only by solver directive in a dispute?
1435+
- `bond_amount_pct` / `bond_base_amount_sats` — bond economics:
1436+
`max(amount_pct * order_amount, base_amount_sats)`.
1437+
- `bond_slash_node_share_pct` — fraction of a slashed bond retained
1438+
by the node (the rest goes to the winning counterparty). Lets the
1439+
user see up-front what they would actually receive.
1440+
- `bond_payout_claim_window_days` — number of days the winning
1441+
counterparty has, from `slashed_at`, to submit a payout invoice
1442+
before forfeiting their share. Clients add this to `slashed_at`
1443+
to render the deadline ("you have N days to claim") in the user's
1444+
own locale; Mostro never ships that text inline on the
1445+
`AddBondInvoice` message itself.
1446+
- **No `bond_slash_dispute` tag**: dispute slashes are solver-driven
14221447
per resolution, not a node-policy switch.
14231448
- Update `docs/admin_settle_order.html` and `admin_cancel_order.html`
14241449
upstream (`mostro.network/protocol/`) with an "Optional payload —

docs/STARTUP_AND_CONFIG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ pub static MESSAGE_QUEUES: LazyLock<MessageQueues> =
165165
LazyLock::new(MessageQueues::default);
166166
```
167167

168-
(`MessageQueues` holds `Arc<RwLock<…>>` queues for order DMs, cant-do messages, rating events, and restore-session messages.)
168+
(`MessageQueues` holds `Arc<RwLock<…>>` queues for order messages, cant-do messages, rating events, and restore-session messages.)
169169

170170
There is **no** database password or separate global for SQLite; the daemon opens the file URL from `[database]` in `settings.toml` only.
171171

src/app/bond/flow.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
//! `taker_*` columns until one bond wins the lock race —
2121
//! [`on_bond_invoice_accepted`] promotes the winner's columns onto the
2222
//! order, cancels every other still-`Requested` bond on the order,
23-
//! and DMs each loser `Action::Canceled`. A malicious taker who never
23+
//! and messages each loser `Action::Canceled`. A malicious taker who never
2424
//! pays their bond does not block the order book: their HTLC expires
2525
//! on its own LND-side TTL and the bond is released.
2626
//!
@@ -221,7 +221,7 @@ pub async fn request_taker_bond(
221221
.await;
222222

223223
// Phase 1.5: park the order at `WaitingTakerBond` while the bond is
224-
// outstanding. The bond subscriber is armed before the DM is sent
224+
// outstanding. The bond subscriber is armed before the message is sent
225225
// (a few lines up), so a fast `Accepted` callback can have already
226226
// transitioned this order to `WaitingPayment` / `WaitingBuyerInvoice`
227227
// by the time we get here; a concurrent maker cancel can have moved
@@ -574,8 +574,8 @@ pub async fn resubscribe_active_bonds(pool: &Arc<Pool<Sqlite>>) -> Result<(), Mo
574574
/// notify our taker, and exit).
575575
/// 3. On `rows_affected == 1` (we won), iterate every other still-
576576
/// `Requested` bond on the order, release each (cancels the LND
577-
/// hold invoice + marks `Released`), and DM `Action::Canceled` to
578-
/// each losing taker.
577+
/// hold invoice + marks `Released`), and message `Action::Canceled`
578+
/// to each losing taker.
579579
/// 4. Copy the winning bond's `taker_*` columns onto the order row
580580
/// (pubkeys, identity, trade index, per-bond pricing, optional
581581
/// buyer invoice), then call `resume_take_after_bond` to drive
@@ -667,7 +667,7 @@ async fn on_bond_invoice_accepted(
667667

668668
// We just won. Tear down every other still-`Requested` bond on
669669
// this order: cancel the LND hold invoice (so the loser
670-
// taker's funds aren't held) and DM them `Action::Canceled`.
670+
// taker's funds aren't held) and message them `Action::Canceled`.
671671
let losers = match find_active_bonds_for_order(pool, current.order_id).await {
672672
Ok(rows) => rows,
673673
Err(e) => {
@@ -736,7 +736,7 @@ async fn on_bond_invoice_accepted(
736736
resume_take_after_bond(pool, order, &my_keys, request_id).await
737737
}
738738

739-
/// DM the taker of a losing concurrent bond that their take was
739+
/// Message the taker of a losing concurrent bond that their take was
740740
/// cancelled because another taker locked their bond first.
741741
async fn notify_loser(bond: &Bond) {
742742
if let Ok(taker_pk) = PublicKey::from_str(&bond.pubkey) {

src/app/bond/model.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,17 @@ pub struct Bond {
7979
/// Number of `send_payment` retries against an invoice the counterparty
8080
/// has already submitted. Bumped only on Phase 3 step 6 (send_payment
8181
/// failure); `payout_max_retries` is checked against this counter
82-
/// alone. Invoice-request DMs do NOT increment this — see
82+
/// alone. Invoice-request messages do NOT increment this — see
8383
/// `invoice_request_attempts`.
8484
pub payout_attempts: i64,
85-
/// Phase 3: number of `Action::AddInvoice` DMs sent to the counterparty
86-
/// asking for a payout invoice. Bounded by the forfeit window
87-
/// (`payout_claim_window_days`), not by `payout_max_retries`. Reset to
88-
/// 0 when the counterparty finally submits an invoice.
85+
/// Phase 3: number of `Action::AddInvoice` messages sent to the
86+
/// counterparty asking for a payout invoice. Bounded by the forfeit
87+
/// window (`payout_claim_window_days`), not by `payout_max_retries`.
88+
/// Reset to 0 when the counterparty finally submits an invoice.
8989
pub invoice_request_attempts: i64,
90-
/// Phase 3: timestamp of the last `Action::AddInvoice` DM. Drives the
91-
/// `payout_invoice_window_seconds` cadence check; persisted so a
92-
/// daemon restart doesn't trigger an immediate re-DM.
90+
/// Phase 3: timestamp of the last `Action::AddInvoice` message. Drives
91+
/// the `payout_invoice_window_seconds` cadence check; persisted so a
92+
/// daemon restart doesn't trigger an immediate re-send.
9393
pub last_invoice_request_at: Option<i64>,
9494
/// Timestamp when the bond hold invoice reached `Accepted`.
9595
pub locked_at: Option<i64>,

0 commit comments

Comments
 (0)