Skip to main content

breez_sdk_spark/sdk/
unilateral_exit_backup.rs

1use bitcoin::{
2    Transaction,
3    consensus::serde::{Hex, With},
4    secp256k1::PublicKey,
5};
6use serde::{Deserialize, Serialize};
7use spark_wallet::{LeafPedigree, Network, SigningKeyshare, TreeNode, TreeNodeId, TreeNodeStatus};
8use tracing::{debug, warn};
9
10use crate::{
11    error::SdkError,
12    models::{
13        ExportUnilateralExitStateResponse, ImportUnilateralExitStateRequest,
14        ImportUnilateralExitStateResponse,
15    },
16};
17
18use super::BreezSdk;
19
20/// The layout exports are written in: transactions as consensus hex.
21const EXIT_STATE_VERSION: u32 = 2;
22/// The layout with transactions in rust-bitcoin's serde struct form. Still read:
23/// exports taken in it stay valid backups.
24const STRUCT_TX_EXIT_STATE_VERSION: u32 = 1;
25
26/// The exported payload. Its pedigrees follow the SDK's internal node types, so
27/// `version` is what keeps an export readable once those internals change.
28#[derive(Debug, Serialize, Deserialize)]
29struct ExitStateEnvelope<P> {
30    version: u32,
31    network: Network,
32    identity_public_key: String,
33    pedigrees: Vec<P>,
34}
35
36/// The field every layout shares, read first to pick the layout.
37#[derive(Deserialize)]
38struct ExitStateVersion {
39    version: u32,
40}
41
42/// `TreeNode` as version 2 writes it: transactions as consensus hex, the form
43/// bitcoin tooling decodes and broadcasts.
44#[derive(Serialize, Deserialize)]
45#[serde(remote = "TreeNode")]
46struct HexTreeNode {
47    id: TreeNodeId,
48    tree_id: String,
49    value: u64,
50    parent_node_id: Option<TreeNodeId>,
51    #[serde(with = "With::<Hex>")]
52    node_tx: Transaction,
53    #[serde(with = "optional_tx_hex")]
54    refund_tx: Option<Transaction>,
55    #[serde(with = "optional_tx_hex")]
56    direct_tx: Option<Transaction>,
57    #[serde(with = "optional_tx_hex")]
58    direct_refund_tx: Option<Transaction>,
59    #[serde(with = "optional_tx_hex")]
60    direct_from_cpfp_refund_tx: Option<Transaction>,
61    vout: u32,
62    verifying_public_key: PublicKey,
63    owner_identity_public_key: Option<PublicKey>,
64    signing_keyshare: SigningKeyshare,
65    status: TreeNodeStatus,
66}
67
68/// Lets a `Vec` hold `HexTreeNode`s: a remote definition only applies through a
69/// field attribute.
70#[derive(Serialize, Deserialize)]
71struct HexNode(#[serde(with = "HexTreeNode")] TreeNode);
72
73/// `LeafPedigree` as version 2 writes it.
74#[derive(Serialize, Deserialize)]
75struct HexLeafPedigree {
76    leaf: HexNode,
77    ancestors: Vec<HexNode>,
78}
79
80impl From<LeafPedigree> for HexLeafPedigree {
81    fn from(LeafPedigree { leaf, ancestors }: LeafPedigree) -> Self {
82        Self {
83            leaf: HexNode(leaf),
84            ancestors: ancestors.into_iter().map(HexNode).collect(),
85        }
86    }
87}
88
89impl From<HexLeafPedigree> for LeafPedigree {
90    fn from(HexLeafPedigree { leaf, ancestors }: HexLeafPedigree) -> Self {
91        Self {
92            leaf: leaf.0,
93            ancestors: ancestors.into_iter().map(|node| node.0).collect(),
94        }
95    }
96}
97
98mod optional_tx_hex {
99    use bitcoin::{Transaction, consensus::encode};
100    use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
101
102    #[allow(clippy::ref_option)]
103    pub(super) fn serialize<S: Serializer>(
104        tx: &Option<Transaction>,
105        serializer: S,
106    ) -> Result<S::Ok, S::Error> {
107        tx.as_ref().map(encode::serialize_hex).serialize(serializer)
108    }
109
110    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
111        deserializer: D,
112    ) -> Result<Option<Transaction>, D::Error> {
113        Option::<String>::deserialize(deserializer)?
114            .map(|hex| encode::deserialize_hex(&hex).map_err(D::Error::custom))
115            .transpose()
116    }
117}
118
119fn encode_exit_state(
120    network: Network,
121    identity_public_key: String,
122    pedigrees: Vec<LeafPedigree>,
123) -> Result<String, SdkError> {
124    let envelope = ExitStateEnvelope {
125        version: EXIT_STATE_VERSION,
126        network,
127        identity_public_key,
128        pedigrees: pedigrees.into_iter().map(HexLeafPedigree::from).collect(),
129    };
130    serde_json::to_string(&envelope)
131        .map_err(|e| SdkError::Generic(format!("Failed to serialize exit state: {e}")))
132}
133
134/// Reads an exit state in any layout this build knows. Rejects one it cannot
135/// use: an unknown version, or another network's state.
136fn decode_exit_state(
137    exit_state: &str,
138    wallet_network: Network,
139) -> Result<ExitStateEnvelope<LeafPedigree>, SdkError> {
140    let ExitStateVersion { version } = parse_exit_state(exit_state)?;
141    let envelope = match version {
142        EXIT_STATE_VERSION => {
143            let envelope: ExitStateEnvelope<HexLeafPedigree> = parse_exit_state(exit_state)?;
144            ExitStateEnvelope {
145                version: envelope.version,
146                network: envelope.network,
147                identity_public_key: envelope.identity_public_key,
148                pedigrees: envelope
149                    .pedigrees
150                    .into_iter()
151                    .map(LeafPedigree::from)
152                    .collect(),
153            }
154        }
155        STRUCT_TX_EXIT_STATE_VERSION => parse_exit_state(exit_state)?,
156        _ => {
157            return Err(SdkError::InvalidInput(format!(
158                "Unsupported exit state version {version}"
159            )));
160        }
161    };
162    if envelope.network != wallet_network {
163        return Err(SdkError::InvalidInput(format!(
164            "Exit state is for network {}, this wallet is on {wallet_network}",
165            envelope.network
166        )));
167    }
168    Ok(envelope)
169}
170
171fn parse_exit_state<'a, T: Deserialize<'a>>(exit_state: &'a str) -> Result<T, SdkError> {
172    serde_json::from_str(exit_state)
173        .map_err(|e| SdkError::InvalidInput(format!("Invalid exit state: {e}")))
174}
175
176#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
177#[allow(clippy::needless_pass_by_value)]
178impl BreezSdk {
179    /// Serializes everything needed to unilaterally exit this wallet's funds
180    /// while the Spark operators are unreachable, so it can be kept somewhere
181    /// the wallet's own storage cannot take with it.
182    ///
183    /// The state goes stale as the wallet is used: export again whenever a
184    /// `UnilateralExitStateChanged` event arrives.
185    pub async fn export_unilateral_exit_state(
186        &self,
187    ) -> Result<ExportUnilateralExitStateResponse, SdkError> {
188        let export = self.spark_wallet.export_exit_state().await?;
189        let leaves = export.pedigrees.len();
190        let exit_state = encode_exit_state(
191            self.config.network.into(),
192            self.spark_wallet.get_identity_public_key().to_string(),
193            export.pedigrees,
194        )?;
195        debug!(
196            leaves,
197            "export_unilateral_exit_state: exit state serialized"
198        );
199
200        Ok(ExportUnilateralExitStateResponse { exit_state })
201    }
202
203    /// Merges a previously exported exit state back into the wallet, without
204    /// contacting the Spark operators. A leaf the exit state does not record
205    /// this wallet as the owner of is skipped.
206    ///
207    /// A leaf the wallet can already exit keeps the data it has: an exit state
208    /// carries no mark of when it was taken, so the imported copy is used only
209    /// where the wallet has nothing that works. Importing an out of date state
210    /// therefore never costs the wallet the ability to exit a leaf.
211    ///
212    /// The exit state must come from the same network the SDK is configured
213    /// for.
214    ///
215    /// An out of date exit state can restore funds that have since been spent,
216    /// so the balance may read high until the next sync reconciles it with the
217    /// Spark operators.
218    pub async fn import_unilateral_exit_state(
219        &self,
220        request: ImportUnilateralExitStateRequest,
221    ) -> Result<ImportUnilateralExitStateResponse, SdkError> {
222        let envelope = decode_exit_state(&request.exit_state, self.config.network.into())?;
223        if envelope.identity_public_key != self.spark_wallet.get_identity_public_key().to_string() {
224            // Not a rejection: the wallet filters leaf by leaf and reports what
225            // it dropped.
226            warn!("Importing an exit state exported by another wallet");
227            debug!("Exporting wallet: {}", envelope.identity_public_key);
228        }
229
230        let imported = self
231            .spark_wallet
232            .import_exit_state(envelope.pedigrees)
233            .await?;
234        let imported_leaves = u32::try_from(imported.imported_leaves)?;
235        let skipped_foreign_leaves = u32::try_from(imported.skipped_foreign_leaves)?;
236        let skipped_conflicting_leaves = u32::try_from(imported.skipped_conflicting_leaves)?;
237        let skipped_chains = u32::try_from(imported.skipped_chains)?;
238        debug!(
239            imported_leaves,
240            skipped_foreign_leaves,
241            skipped_conflicting_leaves,
242            skipped_chains,
243            "import_unilateral_exit_state: exit state merged"
244        );
245
246        Ok(ImportUnilateralExitStateResponse {
247            imported_leaves,
248            skipped_foreign_leaves,
249            skipped_conflicting_leaves,
250            skipped_chains,
251        })
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use bitcoin::{
258        Amount, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Txid, Witness, absolute::LockTime,
259        consensus::encode::serialize_hex, hashes::Hash, transaction::Version,
260    };
261    use serde_json::Value;
262    use spark_wallet::{TreeNodeStatus, tree_store_tests::create_test_node_with_parent};
263
264    use super::*;
265
266    #[cfg(feature = "browser-tests")]
267    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
268
269    /// Shaped like a pre-signed Spark transaction: a key path spend, so it
270    /// carries a witness and takes the segwit encoding.
271    fn signed_tx(sequence: u32) -> Transaction {
272        Transaction {
273            version: Version::non_standard(3),
274            lock_time: LockTime::ZERO,
275            input: vec![TxIn {
276                previous_output: OutPoint::new(Txid::from_byte_array([1; 32]), 0),
277                script_sig: ScriptBuf::new(),
278                sequence: Sequence(sequence),
279                witness: Witness::from_slice(&[[2u8; 64]]),
280            }],
281            output: vec![TxOut {
282                value: Amount::from_sat(1_000),
283                script_pubkey: ScriptBuf::new(),
284            }],
285        }
286    }
287
288    fn regtest_pedigree() -> LeafPedigree {
289        let root = create_test_node_with_parent("root", None, TreeNodeStatus::Splitted);
290        let mut leaf =
291            create_test_node_with_parent("leaf", Some("root"), TreeNodeStatus::Available);
292        leaf.node_tx = signed_tx(0);
293        leaf.refund_tx = Some(signed_tx(1_900));
294        LeafPedigree {
295            leaf,
296            ancestors: vec![root],
297        }
298    }
299
300    fn identity_of(pedigree: &LeafPedigree) -> String {
301        pedigree.leaf.owner_identity_public_key.unwrap().to_string()
302    }
303
304    fn encode_regtest(pedigree: &LeafPedigree) -> String {
305        encode_exit_state(
306            Network::Regtest,
307            identity_of(pedigree),
308            vec![pedigree.clone()],
309        )
310        .unwrap()
311    }
312
313    fn assert_same_pedigree(decoded: &LeafPedigree, original: &LeafPedigree) {
314        assert_eq!(decoded.leaf, original.leaf);
315        assert_eq!(decoded.ancestors, original.ancestors);
316    }
317
318    #[test]
319    fn envelope_round_trips_through_json() {
320        let pedigree = regtest_pedigree();
321        let decoded = decode_exit_state(&encode_regtest(&pedigree), Network::Regtest).unwrap();
322
323        assert_eq!(decoded.version, EXIT_STATE_VERSION);
324        assert_eq!(decoded.network, Network::Regtest);
325        assert_eq!(decoded.identity_public_key, identity_of(&pedigree));
326        assert_eq!(decoded.pedigrees.len(), 1);
327        assert_same_pedigree(&decoded.pedigrees[0], &pedigree);
328    }
329
330    #[test]
331    fn envelope_writes_transactions_as_consensus_hex() {
332        let pedigree = regtest_pedigree();
333        let json: Value = serde_json::from_str(&encode_regtest(&pedigree)).unwrap();
334        let leaf = &json["pedigrees"][0]["leaf"];
335        let root = &json["pedigrees"][0]["ancestors"][0];
336
337        assert_eq!(leaf["node_tx"], serialize_hex(&pedigree.leaf.node_tx));
338        assert_eq!(
339            leaf["refund_tx"],
340            serialize_hex(pedigree.leaf.refund_tx.as_ref().unwrap())
341        );
342        assert_eq!(leaf["direct_tx"], Value::Null);
343        assert_eq!(
344            root["node_tx"],
345            serialize_hex(&pedigree.ancestors[0].node_tx)
346        );
347    }
348
349    #[test]
350    fn envelope_with_struct_form_transactions_is_still_read() {
351        let pedigree = regtest_pedigree();
352        let exit_state = serde_json::to_string(&ExitStateEnvelope {
353            version: STRUCT_TX_EXIT_STATE_VERSION,
354            network: Network::Regtest,
355            identity_public_key: identity_of(&pedigree),
356            pedigrees: vec![pedigree.clone()],
357        })
358        .unwrap();
359        let json: Value = serde_json::from_str(&exit_state).unwrap();
360        assert!(json["pedigrees"][0]["leaf"]["node_tx"].is_object());
361
362        let decoded = decode_exit_state(&exit_state, Network::Regtest).unwrap();
363
364        assert_eq!(decoded.pedigrees.len(), 1);
365        assert_same_pedigree(&decoded.pedigrees[0], &pedigree);
366    }
367
368    #[test]
369    fn envelope_with_unknown_version_is_rejected() {
370        let mut json: Value = serde_json::from_str(&encode_regtest(&regtest_pedigree())).unwrap();
371        json["version"] = (EXIT_STATE_VERSION + 1).into();
372
373        match decode_exit_state(&json.to_string(), Network::Regtest) {
374            Err(SdkError::InvalidInput(message)) => assert!(
375                message.contains("version"),
376                "expected a version complaint, got {message}"
377            ),
378            other => panic!("expected InvalidInput, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn envelope_from_another_network_is_rejected() {
384        match decode_exit_state(&encode_regtest(&regtest_pedigree()), Network::Mainnet) {
385            Err(SdkError::InvalidInput(message)) => assert!(
386                message.contains("network"),
387                "expected a network complaint, got {message}"
388            ),
389            other => panic!("expected InvalidInput, got {other:?}"),
390        }
391    }
392}