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, and the full history of the unilateral exit.
221#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
222pub struct StoredExit {
223	/// Identifier of the VTXO being exited.
224	pub vtxo_id: VtxoId,
225	/// Current exit state.
226	pub state: ExitState,
227	/// Historical states for auditability.
228	pub history: Vec<ExitState>,
229}
230
231impl StoredExit {
232	/// Builds a persistable snapshot from an [ExitVtxo].
233	pub fn new(exit: &ExitVtxo) -> Self {
234		Self {
235			vtxo_id: exit.id(),
236			state: exit.state().clone(),
237			history: exit.history().clone(),
238		}
239	}
240}
241
242/// Exit child transaction for persistence.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct SerdeExitChildTx {
245	#[serde(with = "bitcoin_ext::serde::encodable")]
246	pub child_tx: Transaction,
247	pub origin: ExitTxOrigin,
248}
249
250#[derive(Debug, Clone, Deserialize, Serialize)]
251struct SerdeVtxoRequest<'a> {
252	#[serde(with = "bitcoin::amount::serde::as_sat")]
253	amount: Amount,
254	#[serde(with = "ark::encode::serde")]
255	policy: Cow<'a, VtxoPolicy>,
256}
257
258impl<'a> From<&'a VtxoRequest> for SerdeVtxoRequest<'a> {
259	fn from(v: &'a VtxoRequest) -> Self {
260		Self {
261			amount: v.amount,
262			policy: Cow::Borrowed(&v.policy),
263		}
264	}
265}
266
267impl<'a> From<SerdeVtxoRequest<'a>> for VtxoRequest {
268	fn from(v: SerdeVtxoRequest<'a>) -> Self {
269		VtxoRequest {
270			amount: v.amount,
271			policy: v.policy.into_owned(),
272		}
273	}
274}
275
276/// Model for [RoundParticipation]
277#[derive(Debug, Clone, Serialize, Deserialize)]
278struct SerdeRoundParticipation<'a> {
279	#[serde(with = "ark::encode::serde::cow::vec")]
280	inputs: Cow<'a, [Vtxo<Full>]>,
281	outputs: Vec<SerdeVtxoRequest<'a>>,
282	#[serde(default, skip_serializing_if = "Option::is_none", with = "ark::encode::serde::opt")]
283	unblinded_mailbox_id: Option<MailboxIdentifier>,
284}
285
286impl<'a> From<&'a RoundParticipation> for SerdeRoundParticipation<'a> {
287	fn from(v: &'a RoundParticipation) -> Self {
288	    Self {
289			inputs: Cow::Borrowed(&v.inputs),
290			outputs: v.outputs.iter().map(|v| v.into()).collect(),
291			unblinded_mailbox_id: v.unblinded_mailbox_id,
292		}
293	}
294}
295
296impl<'a> From<SerdeRoundParticipation<'a>> for RoundParticipation {
297	fn from(v: SerdeRoundParticipation<'a>) -> Self {
298		Self {
299			inputs: v.inputs.into_owned(),
300			outputs: v.outputs.into_iter().map(|v| v.into()).collect(),
301			unblinded_mailbox_id: v.unblinded_mailbox_id,
302		}
303	}
304}
305
306/// Placeholder for the now-removed `secret_nonces` field. Discards
307/// any payload on read so legacy records still parse.
308#[derive(Debug, Default)]
309struct PersistedNoncesPlaceholder;
310
311impl ::serde::Serialize for PersistedNoncesPlaceholder {
312	fn serialize<S: ::serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
313		s.collect_seq(std::iter::empty::<()>())
314	}
315}
316
317impl<'de> ::serde::Deserialize<'de> for PersistedNoncesPlaceholder {
318	fn deserialize<D: ::serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
319		::serde::de::IgnoredAny::deserialize(d)?;
320		Ok(PersistedNoncesPlaceholder)
321	}
322}
323
324/// Model for [AttemptState]
325#[derive(Debug, Serialize, Deserialize)]
326enum SerdeAttemptState<'a> {
327	AwaitingAttempt,
328	AwaitingUnsignedVtxoTree {
329		cosign_keys: Cow<'a, [Keypair]>,
330		/// Kept for backward compatibility. See
331		/// [PersistedNoncesPlaceholder].
332		#[serde(rename = "secret_nonces", default)]
333		_legacy_secret_nonces: PersistedNoncesPlaceholder,
334		unlock_hash: UnlockHash,
335	},
336	AwaitingFinishedRound {
337		#[serde(with = "bitcoin_ext::serde::encodable::cow")]
338		unsigned_round_tx: Cow<'a, Transaction>,
339		#[serde(with = "ark::encode::serde")]
340		vtxos_spec: Cow<'a, VtxoTreeSpec>,
341		unlock_hash: UnlockHash,
342	},
343}
344
345impl<'a> From<&'a AttemptState> for SerdeAttemptState<'a> {
346	fn from(state: &'a AttemptState) -> Self {
347		match state {
348			AttemptState::AwaitingAttempt => SerdeAttemptState::AwaitingAttempt,
349			AttemptState::AwaitingUnsignedVtxoTree { cosign_keys, unlock_hash } => {
350				SerdeAttemptState::AwaitingUnsignedVtxoTree {
351					cosign_keys: Cow::Borrowed(cosign_keys),
352					_legacy_secret_nonces: PersistedNoncesPlaceholder,
353					unlock_hash: *unlock_hash,
354				}
355			},
356			AttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
357				SerdeAttemptState::AwaitingFinishedRound {
358					unsigned_round_tx: Cow::Borrowed(unsigned_round_tx),
359					vtxos_spec: Cow::Borrowed(vtxos_spec),
360					unlock_hash: *unlock_hash,
361				}
362			},
363		}
364	}
365}
366
367impl<'a> From<SerdeAttemptState<'a>> for AttemptState {
368	fn from(state: SerdeAttemptState<'a>) -> Self {
369		match state {
370			SerdeAttemptState::AwaitingAttempt => AttemptState::AwaitingAttempt,
371			SerdeAttemptState::AwaitingUnsignedVtxoTree { cosign_keys, _legacy_secret_nonces: _, unlock_hash } => {
372				AttemptState::AwaitingUnsignedVtxoTree {
373					cosign_keys: cosign_keys.into_owned(),
374					unlock_hash: unlock_hash,
375				}
376			},
377			SerdeAttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash } => {
378				AttemptState::AwaitingFinishedRound {
379					unsigned_round_tx: unsigned_round_tx.into_owned(),
380					vtxos_spec: vtxos_spec.into_owned(),
381					unlock_hash: unlock_hash,
382				}
383			},
384		}
385	}
386}
387
388/// Model for [RoundFlowState]
389#[derive(Debug, Serialize, Deserialize)]
390enum SerdeRoundFlowState<'a> {
391	/// We don't do flow and we just wait for the round to finish
392	NonInteractivePending {
393		unlock_hash: UnlockHash,
394	},
395
396	/// Waiting for round to happen
397	InteractivePending,
398	/// Interactive part ongoing
399	InteractiveOngoing {
400		round_seq: RoundSeq,
401		attempt_seq: usize,
402		state: SerdeAttemptState<'a>,
403	},
404
405	/// Interactive part finished, waiting for confirmation
406	Finished {
407		funding_tx: Cow<'a, Transaction>,
408		unlock_hash: UnlockHash,
409	},
410
411	/// Failed during round
412	Failed {
413		error: Cow<'a, str>,
414	},
415
416	/// User canceled round
417	Canceled,
418}
419
420impl<'a> From<&'a RoundFlowState> for SerdeRoundFlowState<'a> {
421	fn from(state: &'a RoundFlowState) -> Self {
422		match state {
423			RoundFlowState::NonInteractivePending { unlock_hash } => {
424				SerdeRoundFlowState::NonInteractivePending {
425					unlock_hash: *unlock_hash,
426				}
427			},
428			RoundFlowState::InteractivePending => SerdeRoundFlowState::InteractivePending,
429			RoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
430				SerdeRoundFlowState::InteractiveOngoing {
431					round_seq: *round_seq,
432					attempt_seq: *attempt_seq,
433					state: state.into(),
434				}
435			},
436			RoundFlowState::Finished { funding_tx, unlock_hash } => {
437				SerdeRoundFlowState::Finished {
438					funding_tx: Cow::Borrowed(funding_tx),
439					unlock_hash: *unlock_hash,
440				}
441			},
442			RoundFlowState::Failed { error } => {
443				SerdeRoundFlowState::Failed {
444					error: Cow::Borrowed(error),
445				}
446			},
447			RoundFlowState::Canceled => SerdeRoundFlowState::Canceled,
448		}
449	}
450}
451
452impl<'a> From<SerdeRoundFlowState<'a>> for RoundFlowState {
453	fn from(state: SerdeRoundFlowState<'a>) -> Self {
454		match state {
455			SerdeRoundFlowState::NonInteractivePending { unlock_hash } => {
456				RoundFlowState::NonInteractivePending { unlock_hash }
457			},
458			SerdeRoundFlowState::InteractivePending => RoundFlowState::InteractivePending,
459			SerdeRoundFlowState::InteractiveOngoing { round_seq, attempt_seq, state } => {
460				RoundFlowState::InteractiveOngoing {
461					round_seq: round_seq,
462					attempt_seq: attempt_seq,
463					state: state.into(),
464				}
465			},
466			SerdeRoundFlowState::Finished { funding_tx, unlock_hash } => {
467				RoundFlowState::Finished {
468					funding_tx: funding_tx.into_owned(),
469					unlock_hash,
470				}
471			},
472			SerdeRoundFlowState::Failed { error } => {
473				RoundFlowState::Failed {
474					error: error.into_owned(),
475				}
476			},
477			SerdeRoundFlowState::Canceled => RoundFlowState::Canceled,
478		}
479	}
480}
481
482/// Model for [RoundState]
483#[derive(Debug, Serialize, Deserialize)]
484pub struct SerdeRoundState<'a> {
485	done: bool,
486	participation: SerdeRoundParticipation<'a>,
487	movement_id: Option<MovementId>,
488	flow: SerdeRoundFlowState<'a>,
489	#[serde(with = "ark::encode::serde::cow::vec")]
490	new_vtxos: Cow<'a, [Vtxo<Full>]>,
491	sent_forfeit_sigs: bool,
492}
493
494impl<'a> From<&'a RoundState> for SerdeRoundState<'a> {
495	fn from(state: &'a RoundState) -> Self {
496		Self {
497			done: state.done,
498			participation: (&state.participation).into(),
499			movement_id: state.movement_id,
500			flow: (&state.flow).into(),
501			new_vtxos: Cow::Borrowed(&state.new_vtxos),
502			sent_forfeit_sigs: state.sent_forfeit_sigs,
503		}
504	}
505}
506
507impl<'a> From<SerdeRoundState<'a>> for RoundState {
508	fn from(state: SerdeRoundState<'a>) -> Self {
509		Self {
510			done: state.done,
511			participation: state.participation.into(),
512			movement_id: state.movement_id,
513			flow: state.flow.into(),
514			new_vtxos: state.new_vtxos.into_owned(),
515			sent_forfeit_sigs: state.sent_forfeit_sigs,
516		}
517	}
518}
519
520#[cfg(test)]
521mod test {
522	use crate::exit::{ExitState, ExitTxOrigin};
523	use crate::vtxo::VtxoState;
524	use super::SerdeAttemptState;
525
526	#[test]
527	/// Each struct stored as JSON in the database should have test to check for backwards compatibility
528	/// Parsing can occur either in convert.rs or this file (query.rs)
529	fn test_serialized_structs() {
530		// Exit state — top-level variants
531		let serialised = r#"{"type":"start","tip_height":119}"#;
532		serde_json::from_str::<ExitState>(serialised).unwrap();
533		let serialised = r#"{"type":"awaiting-delta","tip_height":122,"confirmed_block":"122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f","claimable_height":134}"#;
534		serde_json::from_str::<ExitState>(serialised).unwrap();
535		let serialised = r#"{"type":"claimable","tip_height":134,"claimable_since": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9","last_scanned_block":null}"#;
536		serde_json::from_str::<ExitState>(serialised).unwrap();
537		let serialised = r#"{"type":"claimable","tip_height":140,"claimable_since": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9","last_scanned_block": "139:c6e9eb8c8b4d9620bbe87b94d7fb0fbb8eef1c4a8c1e60f7b3a5d80fe26b0d3e"}"#;
538		serde_json::from_str::<ExitState>(serialised).unwrap();
539		let serialised = r#"{"type":"claim-in-progress","tip_height":134, "claimable_since": "134:6585896bdda6f08d924bf45cc2b16418af56703b3c50930e4dccbc1728d3800a","claim_txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c"}"#;
540		serde_json::from_str::<ExitState>(serialised).unwrap();
541		let serialised = r#"{"type":"claimed","tip_height":134,"txid":"599347c35870bd36f7acb22b81f9ffa8b911d9b5e94834858aebd3ec09339f4c","block": "122:3cdd30fc942301a74666c481beb82050ccd182050aee3c92d2197e8cad427b8f"}"#;
542		serde_json::from_str::<ExitState>(serialised).unwrap();
543
544		// Exit state — `processing` carrying each ExitTxStatus variant. These fixtures
545		// guard against the same class of bug the m0029 migration was written to fix:
546		// renaming, dropping, or reshaping a nested status variant must trip this test.
547		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"verify-inputs"}}]}"#;
548		serde_json::from_str::<ExitState>(serialised).unwrap();
549		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-input-confirmation","txids":["ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de"]}}]}"#;
550		serde_json::from_str::<ExitState>(serialised).unwrap();
551		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-cpfp-broadcast"}}]}"#;
552		serde_json::from_str::<ExitState>(serialised).unwrap();
553		let serialised = r#"{"type":"processing","tip_height":119,"transactions":[{"txid":"9fd34b8c556dd9954bda80ba2cf3474a372702ebc31a366639483e78417c6812","status":{"type":"awaiting-confirmation","child_txid":"ddfe11920358d1a1fae970dc80459c60675bf1392896f69b103fc638313751de","origin":{"type":"wallet","confirmed_in":null}}}]}"#;
554		serde_json::from_str::<ExitState>(serialised).unwrap();
555		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}}}]}"#;
556		serde_json::from_str::<ExitState>(serialised).unwrap();
557		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"}}}]}"#;
558		serde_json::from_str::<ExitState>(serialised).unwrap();
559
560		// Exit child tx origins
561		let serialized = r#"{"type":"wallet","confirmed_in":null}"#;
562		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
563		let serialized = r#"{"type":"wallet","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
564		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
565		// New shape: mempool is a unit variant; fee data lives on ChildTransactionInfo.fee_info.
566		let serialized = r#"{"type":"mempool"}"#;
567		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
568		// Legacy shape: extra fee_rate_kwu/total_fee fields must still deserialize cleanly.
569		let serialized = r#"{"type":"mempool","fee_rate_kwu":25000,"total_fee":27625}"#;
570		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
571		let serialized = r#"{"type":"block","confirmed_in": "134:71fe28f4c803a4c46a3a93d0a9937507d7c20b4bd9586ba317d1109e1aebaac9"}"#;
572		serde_json::from_str::<ExitTxOrigin>(serialized).unwrap();
573
574		// Vtxo state
575		let serialised = r#"{"type": "spendable"}"#;
576		serde_json::from_str::<VtxoState>(serialised).unwrap();
577		let serialised = r#"{"type": "spent"}"#;
578		serde_json::from_str::<VtxoState>(serialised).unwrap();
579		let serialised = r#"{"type": "locked", "movement_id": null}"#;
580		serde_json::from_str::<VtxoState>(serialised).unwrap();
581		let serialised = r#"{"type": "locked", "movement_id": 42}"#;
582		serde_json::from_str::<VtxoState>(serialised).unwrap();
583
584		// Round-attempt state — `AwaitingUnsignedVtxoTree`. Legacy
585		// records carry `secret_nonces` as an array of 132-byte buffers.
586		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"}}"#;
587		serde_json::from_str::<SerdeAttemptState>(serialised).unwrap();
588		let serialised = r#"{"AwaitingUnsignedVtxoTree":{"cosign_keys":[],"unlock_hash":"0000000000000000000000000000000000000000000000000000000000000000"}}"#;
589		serde_json::from_str::<SerdeAttemptState>(serialised).unwrap();
590	}
591
592	/// `SerdeRoundState` is written to sqlite via `rmp_serde` (positional
593	/// MessagePack), so its wire format needs covering separately from
594	/// the JSON fixtures.
595	#[test]
596	fn test_serialized_round_state_msgpack() {
597		use bitcoin::hex::FromHex;
598
599		// Legacy record carrying `secret_nonces`.
600		let serialised = "81b84177616974696e67556e7369676e65645674786f5472656593909191dc0084000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c4200000000000000000000000000000000000000000000000000000000000000000";
601		rmp_serde::from_slice::<SerdeAttemptState>(
602			&Vec::<u8>::from_hex(serialised).unwrap(),
603		).unwrap();
604		// Current record: `secret_nonces` is an empty placeholder seq.
605		let serialised = "81b84177616974696e67556e7369676e65645674786f54726565939090c4200000000000000000000000000000000000000000000000000000000000000000";
606		rmp_serde::from_slice::<SerdeAttemptState>(
607			&Vec::<u8>::from_hex(serialised).unwrap(),
608		).unwrap();
609	}
610}