Skip to main content

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