Skip to main content

bark/persist/
models.rs

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