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