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