1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
//! `Bond` is the database row type for the `bonds` table.
//!
//! String-typed `role` / `state` / `slashed_reason` keep the SQL dump
//! readable. The daemon translates through [`super::types`] when it needs
//! to pattern-match.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use sqlx_crud::SqlxCrud;
use uuid::Uuid;
use super::types::{BondRole, BondState};
/// Database representation of an anti-abuse bond row.
///
/// Created only when `[anti_abuse_bond]` is `enabled = true` and the flow
/// in question matches `apply_to`. A bond row can outlive the trade it was
/// attached to, because a slashed bond still needs a payout to complete;
/// that's why fields that only become meaningful after slash (e.g.
/// `payout_invoice`) are optional.
#[derive(Debug, Default, Clone, Deserialize, Serialize, FromRow, SqlxCrud, PartialEq, Eq)]
#[external_id]
pub struct Bond {
/// Unique identifier for the bond row.
pub id: Uuid,
/// Order the bond is attached to.
pub order_id: Uuid,
/// For Phase 6 child-slash rows: the parent maker bond. `None` on a
/// parent row or on a non-range bond.
pub parent_bond_id: Option<Uuid>,
/// For Phase 6: the child (range-taken) order id this row represents.
/// `None` on a parent row or on a non-range bond.
pub child_order_id: Option<Uuid>,
/// Trade pubkey of the bonded party. Hex-encoded, 64 chars.
pub pubkey: String,
/// `maker` or `taker`. See [`BondRole`].
pub role: String,
/// Amount (sats) this bond row represents.
pub amount_sats: i64,
/// Running total of sats already slashed from a parent bond; used by
/// Phase 6 range-order accounting. 0 for child and non-range rows.
pub slashed_share_sats: i64,
/// Serialized [`BondState`].
pub state: String,
/// Serialized [`super::types::BondSlashReason`]; `None` unless slashed
/// / pending payout.
pub slashed_reason: Option<String>,
/// Bond hold invoice payment hash (hex, 64 chars).
pub hash: Option<String>,
/// Preimage retained by Mostro. `None` on child rows that share the
/// parent HTLC.
///
/// **Secret.** Never serialize: the preimage is the capability that
/// settles the bond HTLC, so leaking it to an audit event, Nostr
/// payload, or RPC response would let a third party race Mostro to
/// claim the bond. `skip_serializing` keeps it out of any serde
/// output a later phase might accidentally introduce; the field is
/// still loaded from the DB via `sqlx::FromRow` as normal.
#[serde(skip_serializing)]
pub preimage: Option<String>,
/// bolt11 payment request shown to the bonded party.
pub payment_request: Option<String>,
/// bolt11 invoice from the winning counterparty (Phase 3+).
///
/// Defense-in-depth: not a capability like `preimage`, but it
/// identifies the winner's node and is payable by anyone who sees
/// it. Kept out of serde output until a phase has a concrete reason
/// to publish it.
#[serde(skip_serializing)]
pub payout_invoice: Option<String>,
/// Routing-fee ceiling actually used for the payout attempt (sats).
pub payout_routing_fee_sats: Option<i64>,
/// bolt11 payment_hash of the counterparty's payout invoice (hex,
/// 64 chars). Written via a CAS guarded on
/// `state = 'pending-payout'` *before* every `send_payment` attempt,
/// so it acts as the idempotency anchor for the counterparty payout
/// leg: if a `send_payment` succeeds but the subsequent
/// `state = 'slashed'` CAS fails (transient DB error, process
/// crash), the next scheduler tick re-enters `pay_counterparty`,
/// sees this hash, reconciles against LND's `track_payment_v2`, and
/// avoids re-invoking `send_payment` against an invoice LND has
/// already paid. Cleared by `apply_payout_invoice` on
/// Failed→PendingPayout resurrection so the new invoice's hash is
/// not shadowed by a stale one.
///
/// Defense-in-depth: not a capability like `preimage`, but it
/// identifies the payment. Kept out of serde output until a phase
/// has a concrete reason to publish it.
#[serde(skip_serializing)]
pub payout_payment_hash: Option<String>,
/// Phase 3: portion of `amount_sats` that the node retains on slash.
/// Frozen at the moment the bond enters `PendingPayout`. `None` until
/// then; the counterparty share is always derived as
/// `amount_sats - node_share_sats` so they cannot drift.
pub node_share_sats: Option<i64>,
/// Number of `send_payment` retries against an invoice the counterparty
/// has already submitted. Bumped only on Phase 3 step 6 (send_payment
/// failure); `payout_max_retries` is checked against this counter
/// alone. Invoice-request messages do NOT increment this — see
/// `invoice_request_attempts`.
pub payout_attempts: i64,
/// Phase 3: number of `Action::AddBondInvoice` messages sent to the
/// counterparty asking for a payout invoice. Bounded by the forfeit
/// window (`payout_claim_window_days`), not by `payout_max_retries`.
/// Reset to 0 when the counterparty finally submits an invoice.
pub invoice_request_attempts: i64,
/// Phase 3: timestamp of the last `Action::AddBondInvoice` message. Drives
/// the `payout_invoice_window_seconds` cadence check; persisted so a
/// daemon restart doesn't trigger an immediate re-send.
pub last_invoice_request_at: Option<i64>,
/// Timestamp when the bond hold invoice reached `Accepted`.
pub locked_at: Option<i64>,
/// Timestamp when the bond transitioned to `Released`.
pub released_at: Option<i64>,
/// Timestamp when the bond transitioned to `PendingPayout` (i.e. when
/// the slash decision was made). Anchors the
/// `payout_claim_window_days` forfeit deadline. Not touched on the
/// later `Slashed` / `Forfeited` / `Failed` transitions.
pub slashed_at: Option<i64>,
/// Timestamp when the row was created.
pub created_at: i64,
/// Concurrent-bonds taker context — the master (identity) pubkey of
/// the prospective taker. Stashed here while the bond races to
/// `Locked` because the order's `master_buyer_pubkey` /
/// `master_seller_pubkey` would otherwise flicker between concurrent
/// takers. Copied onto the order at lock-time.
pub taker_identity: Option<String>,
/// Concurrent-bonds taker context — the trade index from the take
/// message. Copied onto the order's `trade_index_buyer` /
/// `trade_index_seller` at lock-time.
pub taker_trade_index: Option<i64>,
/// Concurrent-bonds taker context — the buyer payout invoice
/// supplied by the taker (sell-order takes only). Copied onto
/// `order.buyer_invoice` at lock-time.
pub taker_invoice: Option<String>,
/// Concurrent-bonds taker context — fiat amount this take committed
/// to for range orders. Copied onto `order.fiat_amount` at lock-time.
pub taker_fiat_amount: Option<i64>,
/// Concurrent-bonds taker context — the per-bond pricing snapshot
/// for market-priced range orders. Copied onto `order.amount` at
/// lock-time so the winner's quote is the one the trade uses.
pub taker_amount: Option<i64>,
/// Concurrent-bonds taker context — per-bond Mostro fee snapshot.
/// Copied onto `order.fee` at lock-time.
pub taker_fee: Option<i64>,
/// Concurrent-bonds taker context — per-bond dev-fee snapshot.
/// Copied onto `order.dev_fee` at lock-time.
pub taker_dev_fee: Option<i64>,
}
impl Bond {
/// Construct a new `Requested` bond row. The caller is responsible for
/// inserting it via `Crud::create`.
pub fn new_requested(order_id: Uuid, pubkey: String, role: BondRole, amount_sats: i64) -> Self {
Self {
id: Uuid::new_v4(),
order_id,
parent_bond_id: None,
child_order_id: None,
pubkey,
role: role.to_string(),
amount_sats,
slashed_share_sats: 0,
state: BondState::Requested.to_string(),
slashed_reason: None,
hash: None,
preimage: None,
payment_request: None,
payout_invoice: None,
payout_routing_fee_sats: None,
payout_payment_hash: None,
node_share_sats: None,
payout_attempts: 0,
invoice_request_attempts: 0,
last_invoice_request_at: None,
locked_at: None,
released_at: None,
slashed_at: None,
created_at: Utc::now().timestamp(),
taker_identity: None,
taker_trade_index: None,
taker_invoice: None,
taker_fiat_amount: None,
taker_amount: None,
taker_fee: None,
taker_dev_fee: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_requested_defaults() {
let order_id = Uuid::new_v4();
let b = Bond::new_requested(order_id, "a".repeat(64), BondRole::Taker, 1_000);
assert_eq!(b.order_id, order_id);
assert_eq!(b.role, "taker");
assert_eq!(b.state, "requested");
assert_eq!(b.amount_sats, 1_000);
assert_eq!(b.slashed_share_sats, 0);
assert!(b.hash.is_none());
assert!(b.node_share_sats.is_none());
assert_eq!(b.payout_attempts, 0);
assert_eq!(b.invoice_request_attempts, 0);
assert!(b.last_invoice_request_at.is_none());
assert!(b.locked_at.is_none());
assert!(b.released_at.is_none());
assert!(b.slashed_at.is_none());
}
#[test]
fn serialize_omits_secret_fields() {
// The preimage is the capability that settles the bond HTLC;
// `payout_invoice` identifies the winner; `payout_payment_hash`
// identifies the payment. All three must stay out of any serde
// output a future phase accidentally adds.
let mut b = Bond::new_requested(Uuid::new_v4(), "a".repeat(64), BondRole::Taker, 1_000);
b.preimage = Some("deadbeef".repeat(8));
b.payout_invoice = Some("lnbc1pSECRET".to_string());
b.hash = Some("c0ffee".repeat(10) + "c0ff");
b.payout_payment_hash = Some("ba5eba11".repeat(8));
let json = serde_json::to_string(&b).expect("serialize");
assert!(!json.contains("preimage"), "preimage leaked: {json}");
assert!(!json.contains("deadbeef"), "preimage value leaked: {json}");
assert!(
!json.contains("payout_invoice"),
"payout_invoice leaked: {json}"
);
assert!(
!json.contains("lnbc1pSECRET"),
"payout_invoice value leaked: {json}"
);
assert!(
!json.contains("payout_payment_hash"),
"payout_payment_hash leaked: {json}"
);
assert!(
!json.contains("ba5eba11"),
"payout_payment_hash value leaked: {json}"
);
// Non-secret fields still serialize as usual.
assert!(json.contains("hash"));
assert!(json.contains("order_id"));
}
}