bark/persist/
models.rs

1//! Persistence-focused data models.
2//!
3//! This module defines serializable types that mirror core in-memory structures but are tailored
4//! for durable storage and retrieval via a BarkPersister implementation.
5//!
6//! Intent
7//! - Keep storage concerns decoupled from runtime types used by protocol logic.
8//! - Provide stable, serde-friendly representations for database backends.
9//! - Enable forward/backward compatibility when schema migrations occur.
10
11use std::borrow::Cow;
12use std::fmt;
13
14use bitcoin::{Amount, Transaction};
15use bitcoin::secp256k1::{Keypair, PublicKey};
16use lightning_invoice::Bolt11Invoice;
17
18use ark::{Vtxo, VtxoId, VtxoPolicy, VtxoRequest};
19use ark::vtxo::Full;
20use ark::musig::DangerousSecretNonce;
21use ark::tree::signed::{UnlockHash, VtxoTreeSpec};
22use ark::lightning::{Invoice, PaymentHash, Preimage};
23use ark::rounds::RoundSeq;
24use bitcoin_ext::BlockDelta;
25
26use crate::WalletVtxo;
27use crate::exit::{ExitState, ExitTxOrigin, ExitVtxo};
28use crate::movement::MovementId;
29use crate::round::{AttemptState, RoundFlowState, RoundParticipation, RoundState, RoundStateGuard};
30use crate::vtxo::VtxoState;
31
32/// VTXO with state history for persistence.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SerdeVtxo {
35	#[serde(with = "ark::encode::serde")]
36	pub vtxo: Vtxo<Full>,
37	/// VTXO states, sorted from oldest to newest.
38	pub states: Vec<VtxoState>,
39}
40
41#[derive(Debug, thiserror::Error)]
42#[error("vtxo has no state")]
43pub struct MissingStateError;
44
45impl SerdeVtxo {
46	pub fn current_state(&self) -> Option<&VtxoState> {
47		self.states.last()
48	}
49
50	pub fn to_wallet_vtxo(&self) -> Result<WalletVtxo, MissingStateError> {
51		Ok(WalletVtxo {
52			vtxo: self.vtxo.clone(),
53			state: self.current_state().cloned().ok_or(MissingStateError)?,
54		})
55	}
56}
57
58/// VTXO key mapping for persistence.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SerdeVtxoKey {
61	pub index: u32,
62	pub public_key: PublicKey,
63}
64
65/// Identifier for a stored [RoundState].
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub struct RoundStateId(pub u32);
68
69impl RoundStateId {
70	pub fn to_bytes(&self) -> [u8; 4] {
71		self.0.to_be_bytes()
72	}
73}
74
75impl fmt::Display for RoundStateId {
76	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77	    fmt::Display::fmt(&self.0, f)
78	}
79}
80
81#[allow(unused)]
82pub struct Locked(RoundStateGuard);
83
84pub struct Unlocked;
85
86pub struct StoredRoundState<G = Locked> {
87	id: RoundStateId,
88	state: RoundState,
89	_guard: G
90}
91
92impl<G> StoredRoundState<G> {
93	pub fn id(&self) -> RoundStateId {
94		self.id
95	}
96
97	pub fn state(&self) -> &RoundState {
98		&self.state
99	}
100}
101
102impl StoredRoundState<Unlocked> {
103	pub fn new(id: RoundStateId, state: RoundState) -> Self {
104		Self { id, state, _guard: Unlocked }
105	}
106
107	pub fn lock(self, guard: RoundStateGuard) -> StoredRoundState {
108		StoredRoundState { id: self.id, state: self.state, _guard: Locked(guard) }
109	}
110}
111
112impl StoredRoundState {
113	pub fn state_mut(&mut self) -> &mut RoundState {
114		&mut self.state
115	}
116
117	pub fn unlock(self) -> StoredRoundState<Unlocked> {
118		StoredRoundState { id: self.id, state: self.state, _guard: Unlocked }
119	}
120}
121
122/// Persisted representation of a pending board.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct PendingBoard {
125	/// This is the [bitcoin::Transaction] that has to
126	/// be confirmed onchain for the board to succeed.
127	#[serde(with = "bitcoin_ext::serde::encodable")]
128	pub funding_tx: Transaction,
129	/// The id of VTXOs being boarded.
130	///
131	/// Currently, this is always a vector of length 1
132	pub vtxos: Vec<VtxoId>,
133	/// The amount of the board.
134	#[serde(with = "bitcoin::amount::serde::as_sat")]
135	pub amount: Amount,
136	/// The [MovementId] associated with this board.
137	pub movement_id: MovementId,
138}
139
140/// Persisted representation of a pending offboard.
141///
142/// Created when an offboard swap is performed, tracked until the
143/// offboard transaction confirms on-chain.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct PendingOffboard {
146	/// The [MovementId] associated with this offboard.
147	pub movement_id: MovementId,
148	/// The txid of the offboard transaction.
149	pub offboard_txid: bitcoin::Txid,
150	/// The full signed offboard transaction.
151	pub offboard_tx: Transaction,
152	/// The VTXOs consumed by this offboard.
153	pub vtxo_ids: Vec<VtxoId>,
154	/// The destination address of the offboard.
155	pub destination: String,
156	/// When this pending offboard was created.
157	pub created_at: chrono::DateTime<chrono::Local>,
158}
159
160/// Persisted representation of a lightning send.
161///
162/// Created after the HTLCs from client to server are constructed.
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct LightningSend {
165	/// The Lightning invoice being paid.
166	pub invoice: Invoice,
167	/// The amount being sent.
168	#[serde(with = "bitcoin::amount::serde::as_sat")]
169	pub amount: Amount,
170	/// The fee paid for making the lightning payment.
171	pub fee: Amount,
172	/// The open HTLCs that are used for this payment.
173	pub htlc_vtxos: Vec<WalletVtxo>,
174	/// The movement associated with this payment.
175	pub movement_id: MovementId,
176	/// The payment preimage, serving as proof of payment.
177	///
178	/// Combined with [`finished_at`](Self::finished_at), determines the payment state:
179	/// - `None` + `finished_at: None` → Pending (in-flight)
180	/// - `None` + `finished_at: Some(_)` → Failed
181	/// - `Some(_)` + `finished_at: Some(_)` → Succeeded
182	pub preimage: Option<Preimage>,
183	/// When the payment reached a terminal state (succeeded or failed).
184	pub finished_at: Option<chrono::DateTime<chrono::Local>>,
185}
186
187/// Persisted representation of an incoming Lightning payment.
188///
189/// Stores the invoice and related cryptographic material (e.g., payment hash and preimage)
190/// and tracks whether the preimage has been revealed.
191///
192/// Note: the record should be removed when the receive is completed or failed.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct LightningReceive {
195	pub payment_hash: PaymentHash,
196	pub payment_preimage: Preimage,
197	pub invoice: Bolt11Invoice,
198	pub preimage_revealed_at: Option<chrono::DateTime<chrono::Local>>,
199	pub htlc_vtxos: Vec<WalletVtxo>,
200	pub htlc_recv_cltv_delta: BlockDelta,
201	pub movement_id: Option<MovementId>,
202	pub finished_at: Option<chrono::DateTime<chrono::Local>>,
203}
204
205/// Persistable view of an [ExitVtxo].
206///
207/// `StoredExit` is a lightweight data transfer object tailored for storage backends. It captures
208/// the VTXO ID, the current state, and the full history of the unilateral exit.
209#[derive(Serialize, Deserialize)]
210pub struct StoredExit {
211	/// Identifier of the VTXO being exited.
212	pub vtxo_id: VtxoId,
213	/// Current exit state.
214	pub state: ExitState,
215	/// Historical states for auditability.
216	pub history: Vec<ExitState>,
217}
218
219impl StoredExit {
220	/// Builds a persistable snapshot from an [ExitVtxo].
221	pub fn new(exit: &ExitVtxo) -> Self {
222		Self {
223			vtxo_id: exit.id(),
224			state: exit.state().clone(),
225			history: exit.history().clone(),
226		}
227	}
228}
229
230/// Exit child transaction for persistence.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct SerdeExitChildTx {
233	#[serde(with = "bitcoin_ext::serde::encodable")]
234	pub child_tx: Transaction,
235	pub origin: ExitTxOrigin,
236}
237
238#[derive(Debug, Clone, Deserialize, Serialize)]
239struct SerdeVtxoRequest<'a> {
240	#[serde(with = "bitcoin::amount::serde::as_sat")]
241	amount: Amount,
242	#[serde(with = "ark::encode::serde")]
243	policy: Cow<'a, VtxoPolicy>,
244}
245
246impl<'a> From<&'a VtxoRequest> for SerdeVtxoRequest<'a> {
247	fn from(v: &'a VtxoRequest) -> Self {
248		Self {
249			amount: v.amount,
250			policy: Cow::Borrowed(&v.policy),
251		}
252	}
253}
254
255impl<'a> From<SerdeVtxoRequest<'a>> for VtxoRequest {
256	fn from(v: SerdeVtxoRequest<'a>) -> Self {
257		VtxoRequest {
258			amount: v.amount,
259			policy: v.policy.into_owned(),
260		}
261	}
262}
263
264/// Model for [RoundParticipation]
265#[derive(Debug, Clone, Serialize, Deserialize)]
266struct SerdeRoundParticipation<'a> {
267	#[serde(with = "ark::encode::serde::cow::vec")]
268	inputs: Cow<'a, [Vtxo<Full>]>,
269	outputs: Vec<SerdeVtxoRequest<'a>>,
270}
271
272impl<'a> From<&'a RoundParticipation> for SerdeRoundParticipation<'a> {
273	fn from(v: &'a RoundParticipation) -> Self {
274	    Self {
275			inputs: Cow::Borrowed(&v.inputs),
276			outputs: v.outputs.iter().map(|v| v.into()).collect(),
277		}
278	}
279}
280
281impl<'a> From<SerdeRoundParticipation<'a>> for RoundParticipation {
282	fn from(v: SerdeRoundParticipation<'a>) -> Self {
283		Self {
284			inputs: v.inputs.into_owned(),
285			outputs: v.outputs.into_iter().map(|v| v.into()).collect(),
286		}
287	}
288}
289
290/// Model for [AttemptState]
291#[derive(Debug, Serialize, Deserialize)]
292enum SerdeAttemptState<'a> {
293	AwaitingAttempt,
294	AwaitingUnsignedVtxoTree {
295		cosign_keys: Cow<'a, [Keypair]>,
296		secret_nonces: Cow<'a, [Vec<DangerousSecretNonce>]>,
297		unlock_hash: UnlockHash,
298	},
299	AwaitingFinishedRound {
300		#[serde(with = "bitcoin_ext::serde::encodable::cow")]
301		unsigned_round_tx: Cow<'a, Transaction>,
302		#[serde(with = "ark::encode::serde")]
303		vtxos_spec: Cow<'a, VtxoTreeSpec>,
304		unlock_hash: UnlockHash,
305	},
306}
307
308impl<'a> From<&'a AttemptState> for SerdeAttemptState<'a> {
309	fn from(state: &'a AttemptState) -> Self {
310		match state {
311			AttemptState::AwaitingAttempt => SerdeAttemptState::AwaitingAttempt,
312			AttemptState::AwaitingUnsignedVtxoTree { cosign_keys, secret_nonces, unlock_hash } => {
313				SerdeAttemptState::AwaitingUnsignedVtxoTree {
314					cosign_keys: Cow::Borrowed(cosign_keys),
315					secret_nonces: Cow::Borrowed(secret_nonces),
316					unlock_hash: *unlock_hash,
317				}
318			},
319			AttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
320				SerdeAttemptState::AwaitingFinishedRound {
321					unsigned_round_tx: Cow::Borrowed(unsigned_round_tx),
322					vtxos_spec: Cow::Borrowed(vtxos_spec),
323					unlock_hash: *unlock_hash,
324				}
325			},
326		}
327	}
328}
329
330impl<'a> From<SerdeAttemptState<'a>> for AttemptState {
331	fn from(state: SerdeAttemptState<'a>) -> Self {
332		match state {
333			SerdeAttemptState::AwaitingAttempt => AttemptState::AwaitingAttempt,
334			SerdeAttemptState::AwaitingUnsignedVtxoTree { cosign_keys, secret_nonces, unlock_hash } => {
335				AttemptState::AwaitingUnsignedVtxoTree {
336					cosign_keys: cosign_keys.into_owned(),
337					secret_nonces: secret_nonces.into_owned(),
338					unlock_hash: unlock_hash,
339				}
340			},
341			SerdeAttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
342				AttemptState::AwaitingFinishedRound {
343					unsigned_round_tx: unsigned_round_tx.into_owned(),
344					vtxos_spec: vtxos_spec.into_owned(),
345					unlock_hash: unlock_hash,
346				}
347			},
348		}
349	}
350}
351
352/// Model for [RoundFlowState]
353#[derive(Debug, Serialize, Deserialize)]
354enum SerdeRoundFlowState<'a> {
355	/// We don't do flow and we just wait for the round to finish
356	NonInteractivePending {
357		unlock_hash: UnlockHash,
358	},
359
360	/// Waiting for round to happen
361	InteractivePending,
362	/// Interactive part ongoing
363	InteractiveOngoing {
364		round_seq: RoundSeq,
365		attempt_seq: usize,
366		state: SerdeAttemptState<'a>,
367	},
368
369	/// Interactive part finished, waiting for confirmation
370	Finished {
371		funding_tx: Cow<'a, Transaction>,
372		unlock_hash: UnlockHash,
373	},
374
375	/// Failed during round
376	Failed {
377		error: Cow<'a, str>,
378	},
379
380	/// User canceled round
381	Canceled,
382}
383
384impl<'a> From<&'a RoundFlowState> for SerdeRoundFlowState<'a> {
385	fn from(state: &'a RoundFlowState) -> Self {
386		match state {
387			RoundFlowState::NonInteractivePending { unlock_hash } => {
388				SerdeRoundFlowState::NonInteractivePending {
389					unlock_hash: *unlock_hash,
390				}
391			},
392			RoundFlowState::InteractivePending => SerdeRoundFlowState::InteractivePending,
393			RoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
394				SerdeRoundFlowState::InteractiveOngoing {
395					round_seq: *round_seq,
396					attempt_seq: *attempt_seq,
397					state: state.into(),
398				}
399			},
400			RoundFlowState::Finished { funding_tx, unlock_hash } => {
401				SerdeRoundFlowState::Finished {
402					funding_tx: Cow::Borrowed(funding_tx),
403					unlock_hash: *unlock_hash,
404				}
405			},
406			RoundFlowState::Failed { error } => {
407				SerdeRoundFlowState::Failed {
408					error: Cow::Borrowed(error),
409				}
410			},
411			RoundFlowState::Canceled => SerdeRoundFlowState::Canceled,
412		}
413	}
414}
415
416impl<'a> From<SerdeRoundFlowState<'a>> for RoundFlowState {
417	fn from(state: SerdeRoundFlowState<'a>) -> Self {
418		match state {
419			SerdeRoundFlowState::NonInteractivePending { unlock_hash } => {
420				RoundFlowState::NonInteractivePending { unlock_hash }
421			},
422			SerdeRoundFlowState::InteractivePending => RoundFlowState::InteractivePending,
423			SerdeRoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
424				RoundFlowState::InteractiveOngoing {
425					round_seq: round_seq,
426					attempt_seq: attempt_seq,
427					state: state.into(),
428				}
429			},
430			SerdeRoundFlowState::Finished { funding_tx, unlock_hash } => {
431				RoundFlowState::Finished {
432					funding_tx: funding_tx.into_owned(),
433					unlock_hash,
434				}
435			},
436			SerdeRoundFlowState::Failed { error } => {
437				RoundFlowState::Failed {
438					error: error.into_owned(),
439				}
440			},
441			SerdeRoundFlowState::Canceled => RoundFlowState::Canceled,
442		}
443	}
444}
445
446/// Model for [RoundState]
447#[derive(Debug, Serialize, Deserialize)]
448pub struct SerdeRoundState<'a> {
449	done: bool,
450	participation: SerdeRoundParticipation<'a>,
451	movement_id: Option<MovementId>,
452	flow: SerdeRoundFlowState<'a>,
453	#[serde(with = "ark::encode::serde::cow::vec")]
454	new_vtxos: Cow<'a, [Vtxo<Full>]>,
455	sent_forfeit_sigs: bool,
456}
457
458impl<'a> From<&'a RoundState> for SerdeRoundState<'a> {
459	fn from(state: &'a RoundState) -> Self {
460		Self {
461			done: state.done,
462			participation: (&state.participation).into(),
463			movement_id: state.movement_id,
464			flow: (&state.flow).into(),
465			new_vtxos: Cow::Borrowed(&state.new_vtxos),
466			sent_forfeit_sigs: state.sent_forfeit_sigs,
467		}
468	}
469}
470
471impl<'a> From<SerdeRoundState<'a>> for RoundState {
472	fn from(state: SerdeRoundState<'a>) -> Self {
473		Self {
474			done: state.done,
475			participation: state.participation.into(),
476			movement_id: state.movement_id,
477			flow: state.flow.into(),
478			new_vtxos: state.new_vtxos.into_owned(),
479			sent_forfeit_sigs: state.sent_forfeit_sigs,
480		}
481	}
482}
483
484#[cfg(test)]
485mod test {
486	use crate::exit::{ExitState, ExitTxOrigin};
487	use crate::vtxo::VtxoState;
488
489	#[test]
490	/// Each struct stored as JSON in the database should have test to check for backwards compatibility
491	/// Parsing can occur either in convert.rs or this file (query.rs)
492	fn test_serialised_structs() {
493		// Exit state
494		let serialised = r#"{"type":"start","tip_height":119}"#;
495		serde_json::from_str::<ExitState>(serialised).unwrap();
496		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-input-confirmation","txids":["ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de"]}}]}"#;
497		serde_json::from_str::<ExitState>(serialised).unwrap();
498		let serialised = r#"{"type":"awaiting-delta","tip_height":122,"confirmed_block":"122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f","claimable_height":134}"#;
499		serde_json::from_str::<ExitState>(serialised).unwrap();
500		let serialised = r#"{"type":"claimable","tip_height":134,"claimable_since": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9","last_scanned_block":null}"#;
501		serde_json::from_str::<ExitState>(serialised).unwrap();
502		let serialised = r#"{"type":"claim-in-progress","tip_height":134, "claimable_since": "134:6585896bdda6f08d924bf45cc2b16418af56703b3c50930e4dccbc1728d3800a","claim_txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c"}"#;
503		serde_json::from_str::<ExitState>(serialised).unwrap();
504		let serialised = r#"{"type":"claimed","tip_height":134,"txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c","block": "122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f"}"#;
505		serde_json::from_str::<ExitState>(serialised).unwrap();
506
507		// Exit child tx origins
508		let serialized = r#"{"type":"wallet","confirmed_in":null}"#;
509		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
510		let serialized = r#"{"type":"wallet","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
511		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
512		let serialized = r#"{"type":"mempool","fee_rate_kwu":25000,"total_fee":27625}"#;
513		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
514		let serialized = r#"{"type":"block","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
515		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
516
517		// Vtxo state
518		let serialised = r#"{"type": "spendable"}"#;
519		serde_json::from_str::<VtxoState>(serialised).unwrap();
520		let serialised = r#"{"type": "spent"}"#;
521		serde_json::from_str::<VtxoState>(serialised).unwrap();
522		let serialised = r#"{"type": "locked", "movement_id": null}"#;
523		serde_json::from_str::<VtxoState>(serialised).unwrap();
524	}
525}