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 bitcoin_ext::BlockHeight;
17use lightning_invoice::Bolt11Invoice;
18
19use ark::{Vtxo, VtxoId, VtxoPolicy, VtxoRequest};
20use ark::vtxo::Full;
21use ark::mailbox::MailboxIdentifier;
22use ark::tree::signed::{UnlockHash, VtxoTreeSpec};
23use ark::lightning::{PaymentHash, Preimage};
24use ark::rounds::RoundSeq;
25
26use crate::WalletVtxo;
27use crate::exit::{ExitState, ExitTxOrigin, ExitVtxo};
28use crate::movement::MovementId;
29use crate::lock_manager::LockGuard;
30use crate::round::{AttemptState, RoundFlowState, RoundParticipation, RoundState};
31use crate::vtxo::VtxoState;
32
33/// VTXO with state history for persistence.
34///
35/// TODO(pc): once the storage adaptor grows a migration framework, switch
36/// this to hold a `Vtxo<Bare>` plus the cached summaries (mirroring the
37/// SQLite `raw_bare`/`raw_genesis` split) and store the genesis bytes in a
38/// sibling record. For now we keep the full VTXO embedded so adaptor
39/// listings still pay the full deserialization cost — this is the
40/// follow-up.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SerdeVtxo {
43	#[serde(with = "ark::encode::serde")]
44	pub vtxo: Vtxo<Full>,
45	/// VTXO states, sorted from oldest to newest.
46	pub states: Vec<VtxoState>,
47	/// See [WalletVtxo::registered]. Defaults to `false` for
48	/// records stored before this field existed, so they get caught up.
49	#[serde(default)]
50	pub registered: bool,
51}
52
53#[derive(Debug, thiserror::Error)]
54#[error("vtxo has no state")]
55pub struct MissingStateError;
56
57impl SerdeVtxo {
58	pub fn current_state(&self) -> Option<&VtxoState> {
59		self.states.last()
60	}
61
62	pub fn to_wallet_vtxo(&self) -> Result<WalletVtxo, MissingStateError> {
63		let state = self.current_state().cloned().ok_or(MissingStateError)?;
64		Ok(wallet_vtxo_from_full(&self.vtxo, state, self.registered))
65	}
66}
67
68/// Project a stored full VTXO into the bare-shaped [WalletVtxo] the wallet
69/// hot paths consume, computing the cached `exit_depth` and
70/// `exit_tx_weight` summaries on the fly.
71///
72/// SQLite stores those summaries as columns and reads them without touching
73/// the genesis chain; the adaptor backend currently does not split storage,
74/// so it has to deserialize the full vtxo first and compute the summaries
75/// here. Once the adaptor gains a migration framework this helper goes away
76/// in favor of a true split.
77pub(crate) fn wallet_vtxo_from_full(
78	vtxo: &Vtxo<Full>,
79	state: VtxoState,
80	registered: bool,
81) -> WalletVtxo {
82	WalletVtxo {
83		vtxo: vtxo.to_bare(),
84		state,
85		exit_depth: vtxo.exit_depth(),
86		exit_tx_weight: vtxo.transactions().map(|t| t.tx.weight()).sum(),
87		registered,
88	}
89}
90
91/// VTXO key mapping for persistence.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct SerdeVtxoKey {
94	pub index: u32,
95	pub public_key: PublicKey,
96}
97
98/// Identifier for a stored [RoundState].
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub struct RoundStateId(pub u32);
101
102impl RoundStateId {
103	pub fn to_bytes(&self) -> [u8; 4] {
104		self.0.to_be_bytes()
105	}
106}
107
108impl fmt::Display for RoundStateId {
109	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110	    fmt::Display::fmt(&self.0, f)
111	}
112}
113
114#[allow(unused)]
115pub struct Locked(Box<dyn LockGuard>);
116
117pub struct Unlocked;
118
119pub struct StoredRoundState<G = Locked> {
120	id: RoundStateId,
121	state: RoundState,
122	_guard: G
123}
124
125impl<G> StoredRoundState<G> {
126	pub fn id(&self) -> RoundStateId {
127		self.id
128	}
129
130	pub fn state(&self) -> &RoundState {
131		&self.state
132	}
133}
134
135impl StoredRoundState<Unlocked> {
136	pub fn new(id: RoundStateId, state: RoundState) -> Self {
137		Self { id, state, _guard: Unlocked }
138	}
139
140	pub fn lock(self, guard: Box<dyn LockGuard>) -> StoredRoundState {
141		StoredRoundState { id: self.id, state: self.state, _guard: Locked(guard) }
142	}
143}
144
145impl StoredRoundState<Locked> {
146	pub fn state_mut(&mut self) -> &mut RoundState {
147		&mut self.state
148	}
149
150	pub fn unlock(self) -> StoredRoundState<Unlocked> {
151		StoredRoundState { id: self.id, state: self.state, _guard: Unlocked }
152	}
153}
154
155/// Persisted representation of a pending board.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct PendingBoard {
158	/// This is the [bitcoin::Transaction] that has to
159	/// be confirmed onchain for the board to succeed.
160	#[serde(with = "bitcoin_ext::serde::encodable")]
161	pub funding_tx: Transaction,
162	/// The id of VTXOs being boarded.
163	///
164	/// Currently, this is always a vector of length 1
165	pub vtxos: Vec<VtxoId>,
166	/// The amount of the board.
167	#[serde(with = "bitcoin::amount::serde::as_sat")]
168	pub amount: Amount,
169	/// The [MovementId] associated with this board.
170	pub movement_id: MovementId,
171}
172
173/// Replay-protection record for a fully-settled outgoing lightning send.
174///
175/// Written when a payment is acknowledged with a valid preimage; never
176/// deleted. Used by [`crate::actions::lightning::pay`] to refuse paying
177/// the same invoice twice.
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179pub struct PaidInvoice {
180	pub payment_hash: PaymentHash,
181	pub preimage: Preimage,
182	pub paid_at: chrono::DateTime<chrono::Local>,
183}
184
185/// Permanent record of a fully-settled incoming lightning receive.
186///
187/// Written when an inbound payment is claimed (the wallet has obtained
188/// spendable VTXOs in exchange for the preimage); never deleted.
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct SettledLightningReceive {
191	pub payment_hash: PaymentHash,
192	pub preimage: Preimage,
193	pub invoice: Bolt11Invoice,
194	pub amount: Amount,
195	pub settled_at: chrono::DateTime<chrono::Local>,
196}
197
198/// Persistable view of an [ExitVtxo].
199///
200/// `StoredExit` is a lightweight data transfer object tailored for storage backends. It captures
201/// the VTXO ID, the current state, the full history of the unilateral exit, and a pointer
202/// back to the pending movement that records this exit.
203#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
204pub struct StoredExit {
205	/// Identifier of the VTXO being exited.
206	pub vtxo_id: VtxoId,
207	/// Current exit state.
208	pub state: ExitState,
209	/// Historical states for auditability.
210	pub history: Vec<ExitState>,
211	/// The movement that records this exit. `None` for exits created before
212	/// movement tracking was wired up.
213	pub movement_id: Option<MovementId>,
214}
215
216impl StoredExit {
217	/// Builds a persistable snapshot from an [ExitVtxo].
218	pub fn new(exit: &ExitVtxo) -> Self {
219		Self {
220			vtxo_id: exit.id(),
221			state: exit.state().clone(),
222			history: exit.history().clone(),
223			movement_id: exit.movement_id(),
224		}
225	}
226}
227
228/// Exit child transaction for persistence.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct SerdeExitChildTx {
231	#[serde(with = "bitcoin_ext::serde::encodable")]
232	pub child_tx: Transaction,
233	pub origin: ExitTxOrigin,
234}
235
236#[derive(Debug, Clone, Deserialize, Serialize)]
237struct SerdeVtxoRequest<'a> {
238	#[serde(with = "bitcoin::amount::serde::as_sat")]
239	amount: Amount,
240	#[serde(with = "ark::encode::serde")]
241	policy: Cow<'a, VtxoPolicy>,
242}
243
244impl<'a> From<&'a VtxoRequest> for SerdeVtxoRequest<'a> {
245	fn from(v: &'a VtxoRequest) -> Self {
246		Self {
247			amount: v.amount,
248			policy: Cow::Borrowed(&v.policy),
249		}
250	}
251}
252
253impl<'a> From<SerdeVtxoRequest<'a>> for VtxoRequest {
254	fn from(v: SerdeVtxoRequest<'a>) -> Self {
255		VtxoRequest {
256			amount: v.amount,
257			policy: v.policy.into_owned(),
258		}
259	}
260}
261
262/// Model for [RoundParticipation]
263#[derive(Debug, Clone, Serialize, Deserialize)]
264struct SerdeRoundParticipation<'a> {
265	#[serde(with = "ark::encode::serde::cow::vec")]
266	inputs: Cow<'a, [Vtxo<Full>]>,
267	outputs: Vec<SerdeVtxoRequest<'a>>,
268	#[serde(default, skip_serializing_if = "Option::is_none", with = "ark::encode::serde::opt")]
269	unblinded_mailbox_id: Option<MailboxIdentifier>,
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			unblinded_mailbox_id: v.unblinded_mailbox_id,
278		}
279	}
280}
281
282impl<'a> From<SerdeRoundParticipation<'a>> for RoundParticipation {
283	fn from(v: SerdeRoundParticipation<'a>) -> Self {
284		Self {
285			inputs: v.inputs.into_owned(),
286			outputs: v.outputs.into_iter().map(|v| v.into()).collect(),
287			unblinded_mailbox_id: v.unblinded_mailbox_id,
288		}
289	}
290}
291
292/// Placeholder for the now-removed `secret_nonces` field. Discards
293/// any payload on read so legacy records still parse.
294#[derive(Debug, Default)]
295struct PersistedNoncesPlaceholder;
296
297impl ::serde::Serialize for PersistedNoncesPlaceholder {
298	fn serialize<S: ::serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
299		s.collect_seq(std::iter::empty::<()>())
300	}
301}
302
303impl<'de> ::serde::Deserialize<'de> for PersistedNoncesPlaceholder {
304	fn deserialize<D: ::serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
305		::serde::de::IgnoredAny::deserialize(d)?;
306		Ok(PersistedNoncesPlaceholder)
307	}
308}
309
310/// Model for [AttemptState]
311#[derive(Debug, Serialize, Deserialize)]
312enum SerdeAttemptState<'a> {
313	AwaitingAttempt,
314	AwaitingUnsignedVtxoTree {
315		cosign_keys: Cow<'a, [Keypair]>,
316		/// Kept for backward compatibility. See
317		/// [PersistedNoncesPlaceholder].
318		#[serde(rename = "secret_nonces", default)]
319		_legacy_secret_nonces: PersistedNoncesPlaceholder,
320		unlock_hash: UnlockHash,
321	},
322	AwaitingFinishedRound {
323		#[serde(with = "bitcoin_ext::serde::encodable::cow")]
324		unsigned_round_tx: Cow<'a, Transaction>,
325		#[serde(with = "ark::encode::serde")]
326		vtxos_spec: Cow<'a, VtxoTreeSpec>,
327		unlock_hash: UnlockHash,
328	},
329}
330
331impl<'a> From<&'a AttemptState> for SerdeAttemptState<'a> {
332	fn from(state: &'a AttemptState) -> Self {
333		match state {
334			AttemptState::AwaitingAttempt => SerdeAttemptState::AwaitingAttempt,
335			AttemptState::AwaitingUnsignedVtxoTree { cosign_keys, unlock_hash } => {
336				SerdeAttemptState::AwaitingUnsignedVtxoTree {
337					cosign_keys: Cow::Borrowed(cosign_keys),
338					_legacy_secret_nonces: PersistedNoncesPlaceholder,
339					unlock_hash: *unlock_hash,
340				}
341			},
342			AttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
343				SerdeAttemptState::AwaitingFinishedRound {
344					unsigned_round_tx: Cow::Borrowed(unsigned_round_tx),
345					vtxos_spec: Cow::Borrowed(vtxos_spec),
346					unlock_hash: *unlock_hash,
347				}
348			},
349		}
350	}
351}
352
353impl<'a> From<SerdeAttemptState<'a>> for AttemptState {
354	fn from(state: SerdeAttemptState<'a>) -> Self {
355		match state {
356			SerdeAttemptState::AwaitingAttempt => AttemptState::AwaitingAttempt,
357			SerdeAttemptState::AwaitingUnsignedVtxoTree { cosign_keys, _legacy_secret_nonces: _, unlock_hash } => {
358				AttemptState::AwaitingUnsignedVtxoTree {
359					cosign_keys: cosign_keys.into_owned(),
360					unlock_hash: unlock_hash,
361				}
362			},
363			SerdeAttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
364				AttemptState::AwaitingFinishedRound {
365					unsigned_round_tx: unsigned_round_tx.into_owned(),
366					vtxos_spec: vtxos_spec.into_owned(),
367					unlock_hash: unlock_hash,
368				}
369			},
370		}
371	}
372}
373
374/// Model for [RoundFlowState]
375#[derive(Debug, Serialize, Deserialize)]
376enum SerdeRoundFlowState<'a> {
377	/// We don't do flow and we just wait for the round to finish
378	NonInteractivePending {
379		unlock_hash: UnlockHash,
380		/// Absent in states stored before scheduled participations were tracked.
381		#[serde(default)]
382		scheduled_height: Option<BlockHeight>,
383	},
384
385	/// Waiting for round to happen
386	InteractivePending,
387	/// Interactive part ongoing
388	InteractiveOngoing {
389		round_seq: RoundSeq,
390		attempt_seq: usize,
391		state: SerdeAttemptState<'a>,
392	},
393
394	/// Interactive part finished, waiting for confirmation
395	Finished {
396		funding_tx: Cow<'a, Transaction>,
397		unlock_hash: UnlockHash,
398	},
399
400	/// Failed during round
401	Failed {
402		error: Cow<'a, str>,
403	},
404
405	/// User canceled round
406	Canceled,
407}
408
409impl<'a> From<&'a RoundFlowState> for SerdeRoundFlowState<'a> {
410	fn from(state: &'a RoundFlowState) -> Self {
411		match state {
412			RoundFlowState::NonInteractivePending { unlock_hash, scheduled_height } => {
413				SerdeRoundFlowState::NonInteractivePending {
414					unlock_hash: *unlock_hash,
415					scheduled_height: *scheduled_height,
416				}
417			},
418			RoundFlowState::InteractivePending => SerdeRoundFlowState::InteractivePending,
419			RoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
420				SerdeRoundFlowState::InteractiveOngoing {
421					round_seq: *round_seq,
422					attempt_seq: *attempt_seq,
423					state: state.into(),
424				}
425			},
426			RoundFlowState::Finished { funding_tx, unlock_hash } => {
427				SerdeRoundFlowState::Finished {
428					funding_tx: Cow::Borrowed(funding_tx),
429					unlock_hash: *unlock_hash,
430				}
431			},
432			RoundFlowState::Failed { error } => {
433				SerdeRoundFlowState::Failed {
434					error: Cow::Borrowed(error),
435				}
436			},
437			RoundFlowState::Canceled => SerdeRoundFlowState::Canceled,
438		}
439	}
440}
441
442impl<'a> From<SerdeRoundFlowState<'a>> for RoundFlowState {
443	fn from(state: SerdeRoundFlowState<'a>) -> Self {
444		match state {
445			SerdeRoundFlowState::NonInteractivePending { unlock_hash, scheduled_height } => {
446				RoundFlowState::NonInteractivePending { unlock_hash, scheduled_height }
447			},
448			SerdeRoundFlowState::InteractivePending => RoundFlowState::InteractivePending,
449			SerdeRoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
450				RoundFlowState::InteractiveOngoing {
451					round_seq: round_seq,
452					attempt_seq: attempt_seq,
453					state: state.into(),
454				}
455			},
456			SerdeRoundFlowState::Finished { funding_tx, unlock_hash } => {
457				RoundFlowState::Finished {
458					funding_tx: funding_tx.into_owned(),
459					unlock_hash,
460				}
461			},
462			SerdeRoundFlowState::Failed { error } => {
463				RoundFlowState::Failed {
464					error: error.into_owned(),
465				}
466			},
467			SerdeRoundFlowState::Canceled => RoundFlowState::Canceled,
468		}
469	}
470}
471
472/// Model for [RoundState]
473#[derive(Debug, Serialize, Deserialize)]
474pub struct SerdeRoundState<'a> {
475	done: bool,
476	participation: SerdeRoundParticipation<'a>,
477	movement_id: Option<MovementId>,
478	flow: SerdeRoundFlowState<'a>,
479	#[serde(with = "ark::encode::serde::cow::vec")]
480	new_vtxos: Cow<'a, [Vtxo<Full>]>,
481	sent_forfeit_sigs: bool,
482}
483
484impl<'a> From<&'a RoundState> for SerdeRoundState<'a> {
485	fn from(state: &'a RoundState) -> Self {
486		Self {
487			done: state.done,
488			participation: (&state.participation).into(),
489			movement_id: state.movement_id,
490			flow: (&state.flow).into(),
491			new_vtxos: Cow::Borrowed(&state.new_vtxos),
492			sent_forfeit_sigs: state.sent_forfeit_sigs,
493		}
494	}
495}
496
497impl<'a> From<SerdeRoundState<'a>> for RoundState {
498	fn from(state: SerdeRoundState<'a>) -> Self {
499		Self {
500			done: state.done,
501			participation: state.participation.into(),
502			movement_id: state.movement_id,
503			flow: state.flow.into(),
504			new_vtxos: state.new_vtxos.into_owned(),
505			sent_forfeit_sigs: state.sent_forfeit_sigs,
506		}
507	}
508}
509
510#[cfg(test)]
511mod test {
512	use crate::exit::{ExitState, ExitTxOrigin};
513	use crate::vtxo::VtxoState;
514	use super::SerdeAttemptState;
515
516	#[test]
517	/// Each struct stored as JSON in the database should have test to check for backwards compatibility
518	/// Parsing can occur either in convert.rs or this file (query.rs)
519	fn test_serialized_structs() {
520		// Exit state — top-level variants
521		let serialised = r#"{"type":"start","tip_height":119}"#;
522		serde_json::from_str::<ExitState>(serialised).unwrap();
523		let serialised = r#"{"type":"awaiting-delta","tip_height":122,"confirmed_block":"122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f","claimable_height":134}"#;
524		serde_json::from_str::<ExitState>(serialised).unwrap();
525		let serialised = r#"{"type":"claimable","tip_height":134,"claimable_since": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9","last_scanned_block":null}"#;
526		serde_json::from_str::<ExitState>(serialised).unwrap();
527		let serialised = r#"{"type":"claimable","tip_height":140,"claimable_since": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9","last_scanned_block": "139:c6e9eb8c8b4d9620bbe87b94d7fb0fbb8eef1c4a8c1e60f7b3a5d80fe26b0d3e"}"#;
528		serde_json::from_str::<ExitState>(serialised).unwrap();
529		let serialised = r#"{"type":"claim-in-progress","tip_height":134, "claimable_since": "134:6585896bdda6f08d924bf45cc2b16418af56703b3c50930e4dccbc1728d3800a","claim_txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c"}"#;
530		serde_json::from_str::<ExitState>(serialised).unwrap();
531		let serialised = r#"{"type":"claimed","tip_height":134,"txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c","block": "122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f"}"#;
532		serde_json::from_str::<ExitState>(serialised).unwrap();
533		let serialised = r#"{"type":"vtxo-already-spent","tip_height":135}"#;
534		serde_json::from_str::<ExitState>(serialised).unwrap();
535		let serialised = r#"{"type":"canceled","tip_height":135}"#;
536		serde_json::from_str::<ExitState>(serialised).unwrap();
537
538		// Exit state — `processing` carrying each ExitTxStatus variant. These fixtures
539		// guard against the same class of bug the m0029 migration was written to fix:
540		// renaming, dropping, or reshaping a nested status variant must trip this test.
541		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"verify-inputs"}}]}"#;
542		serde_json::from_str::<ExitState>(serialised).unwrap();
543		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-input-confirmation","txids":["ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de"]}}]}"#;
544		serde_json::from_str::<ExitState>(serialised).unwrap();
545		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-cpfp-broadcast"}}]}"#;
546		serde_json::from_str::<ExitState>(serialised).unwrap();
547		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-confirmation","child_txid":"ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de","origin":{"type":"wallet","confirmed_in":null}}}]}"#;
548		serde_json::from_str::<ExitState>(serialised).unwrap();
549		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-confirmation","child_txid":"ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de","origin":{"type":"mempool","fee_rate_kwu":25000,"total_fee":27625}}}]}"#;
550		serde_json::from_str::<ExitState>(serialised).unwrap();
551		let serialised = r#"{"type":"processing","tip_height":134,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"confirmed","child_txid":"ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de","block":"122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f","origin":{"type":"block","confirmed_in":"122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f"}}}]}"#;
552		serde_json::from_str::<ExitState>(serialised).unwrap();
553
554		// Exit child tx origins
555		let serialized = r#"{"type":"wallet","confirmed_in":null}"#;
556		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
557		let serialized = r#"{"type":"wallet","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
558		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
559		// New shape: mempool is a unit variant; fee data lives on ChildTransactionInfo.fee_info.
560		let serialized = r#"{"type":"mempool"}"#;
561		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
562		// Legacy shape: extra fee_rate_kwu/total_fee fields must still deserialize cleanly.
563		let serialized = r#"{"type":"mempool","fee_rate_kwu":25000,"total_fee":27625}"#;
564		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
565		let serialized = r#"{"type":"block","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
566		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
567
568		// Vtxo state
569		let serialised = r#"{"type": "spendable"}"#;
570		serde_json::from_str::<VtxoState>(serialised).unwrap();
571		let serialised = r#"{"type": "spent"}"#;
572		serde_json::from_str::<VtxoState>(serialised).unwrap();
573		let serialised = r#"{"type": "exited"}"#;
574		serde_json::from_str::<VtxoState>(serialised).unwrap();
575		// Legacy locked shape: pre-holder records carry `movement_id` instead.
576		let serialised = r#"{"type": "locked", "movement_id": null}"#;
577		serde_json::from_str::<VtxoState>(serialised).unwrap();
578		let serialised = r#"{"type": "locked", "movement_id": 42}"#;
579		serde_json::from_str::<VtxoState>(serialised).unwrap();
580		// Current locked shapes: `holder` is absent, null, or one of the VtxoLockHolder variants.
581		let serialised = r#"{"type": "locked", "holder": null}"#;
582		serde_json::from_str::<VtxoState>(serialised).unwrap();
583		let serialised = r#"{"type": "locked", "holder": {"type": "movement", "id": 42}}"#;
584		serde_json::from_str::<VtxoState>(serialised).unwrap();
585		let serialised = r#"{"type": "locked", "holder": {"type": "action", "id": "test-action-id"}}"#;
586		serde_json::from_str::<VtxoState>(serialised).unwrap();
587
588		// Round-attempt state — `AwaitingUnsignedVtxoTree`. Legacy
589		// records carry `secret_nonces` as an array of 132-byte buffers.
590		let serialised = r#"{"AwaitingUnsignedVtxoTree":{"cosign_keys":[],"secret_nonces":[[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]],"unlock_hash":"0000000000000000000000000000000000000000000000000000000000000000"}}"#;
591		serde_json::from_str::<SerdeAttemptState>(serialised).unwrap();
592		let serialised = r#"{"AwaitingUnsignedVtxoTree":{"cosign_keys":[],"unlock_hash":"0000000000000000000000000000000000000000000000000000000000000000"}}"#;
593		serde_json::from_str::<SerdeAttemptState>(serialised).unwrap();
594	}
595
596	/// `SerdeRoundState` is written to sqlite via `rmp_serde` (positional
597	/// MessagePack), so its wire format needs covering separately from
598	/// the JSON fixtures.
599	#[test]
600	fn test_serialized_round_state_msgpack() {
601		use bitcoin::hex::FromHex;
602
603		// Legacy record carrying `secret_nonces`.
604		let serialised = "81b84177616974696e67556e7369676e65645674786f5472656593909191dc0084000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c4200000000000000000000000000000000000000000000000000000000000000000";
605		rmp_serde::from_slice::<SerdeAttemptState>(
606			&Vec::<u8>::from_hex(serialised).unwrap(),
607		).unwrap();
608		// Current record: `secret_nonces` is an empty placeholder seq.
609		let serialised = "81b84177616974696e67556e7369676e65645674786f54726565939090c4200000000000000000000000000000000000000000000000000000000000000000";
610		rmp_serde::from_slice::<SerdeAttemptState>(
611			&Vec::<u8>::from_hex(serialised).unwrap(),
612		).unwrap();
613	}
614
615	/// `NonInteractivePending` gained a trailing `scheduled_height` field. Since
616	/// `rmp_serde` encodes struct variants positionally, legacy records are a
617	/// shorter array and must still deserialize (with `scheduled_height: None`).
618	#[test]
619	fn test_serialized_non_interactive_pending_scheduled_height() {
620		use bitcoin::hashes::Hash;
621		use super::{SerdeRoundFlowState, UnlockHash};
622
623		// Legacy shape: the variant payload held only `unlock_hash`.
624		#[derive(serde::Serialize)]
625		enum LegacyRoundFlowState {
626			NonInteractivePending { unlock_hash: UnlockHash },
627		}
628
629		let unlock_hash = UnlockHash::all_zeros();
630		let legacy = rmp_serde::to_vec(
631			&LegacyRoundFlowState::NonInteractivePending { unlock_hash },
632		).unwrap();
633		match rmp_serde::from_slice::<SerdeRoundFlowState>(&legacy).unwrap() {
634			SerdeRoundFlowState::NonInteractivePending { unlock_hash: uh, scheduled_height } => {
635				assert_eq!(uh, unlock_hash);
636				assert_eq!(scheduled_height, None);
637			},
638			_ => panic!("wrong variant"),
639		}
640
641		// Current shape round-trips a stored schedule height.
642		let current = rmp_serde::to_vec(&SerdeRoundFlowState::NonInteractivePending {
643			unlock_hash,
644			scheduled_height: Some(123_456),
645		}).unwrap();
646		match rmp_serde::from_slice::<SerdeRoundFlowState>(&current).unwrap() {
647			SerdeRoundFlowState::NonInteractivePending { scheduled_height, .. } => {
648				assert_eq!(scheduled_height, Some(123_456));
649			},
650			_ => panic!("wrong variant"),
651		}
652	}
653}