spark_rust/wallet/handlers/
cooperative_exit.rs

1use crate::with_handler_lock;
2use crate::{
3    constants::spark::{DEFAULT_COOPERATIVE_EXIT_EXPIRY, DEFAULT_WITHDRAWAL_AMOUNT},
4    error::{IoError, SparkSdkError, ValidationError},
5    signer::traits::{derivation_path::SparkKeyType, SparkSigner},
6    wallet::{
7        internal_handlers::traits::{
8            cooperative_exit::CooperativeExitInternalHandlers,
9            leaves::LeavesInternalHandlers,
10            ssp::SspInternalHandlers,
11            transfer::{LeafKeyTweak, TransferInternalHandlers},
12        },
13        leaf_manager::SparkNodeStatus,
14        utils::transaction::create_connector_refund_tx,
15    },
16    SparkSdk,
17};
18use bitcoin::{secp256k1::PublicKey, Address, OutPoint, Transaction, Txid};
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22// An opaque ID representing a cooperative exit request.
23#[derive(Serialize, Deserialize, Debug, Clone)]
24pub struct CoopExitRequestId(pub String);
25
26#[derive(Serialize, Deserialize, Debug, Clone)]
27pub struct CoopExitResponse {
28    pub request_id: CoopExitRequestId,
29    pub exit_txid: Txid,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone)]
33pub enum CoopExitStatus {
34    Pending,
35    Completed,
36}
37
38/// Input for requesting a cooperative exit
39pub struct RequestCoopExitInput {
40    /// The leaf external IDs to exit
41    pub leaf_external_ids: Uuid,
42    /// The withdrawal address
43    pub withdrawal_address: Address,
44}
45
46/// Input for completing a cooperative exit
47#[derive(Serialize, Deserialize)]
48pub struct CompleteCoopExitInput {
49    /// The user outbound transfer external ID
50    pub user_outbound_transfer_external_id: String,
51    /// The cooperative exit request ID
52    pub coop_exit_request_id: CoopExitRequestId,
53}
54
55/// Cooperative exit request response
56#[derive(Serialize, Deserialize, Debug, Clone)]
57pub struct CoopExitRequest {
58    /// The ID of the request
59    pub id: CoopExitRequestId,
60    /// The raw connector transaction. Only present if the request is pending.
61    pub raw_connector_transaction: Option<Transaction>,
62    /// The status of the request
63    pub status: CoopExitStatus,
64    /// The timestamp of the request
65    pub created_at: u64,
66    /// The timestamp of the last update
67    pub updated_at: u64,
68}
69
70/// Represents a signing nonce with binding and hiding keys
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SigningNonce {
73    /// The binding key
74    pub binding: Vec<u8>,
75    /// The hiding key
76    pub hiding: Vec<u8>,
77}
78
79/// Represents a signing commitment with binding and hiding public keys
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SigningCommitment {
82    /// The binding public key
83    pub binding: Vec<u8>,
84    /// The hiding public key
85    pub hiding: Vec<u8>,
86}
87
88/// CoopExitService implementation for Rust
89/// This is a 1-to-1 port of the TypeScript CoopExitService class
90impl<S: SparkSigner + Send + Sync + Clone + 'static> SparkSdk<S> {
91    /// Initiates a withdrawal to move funds from the Spark network to an on-chain Bitcoin address.
92    ///
93    /// This function allows you to exit the Spark network by withdrawing funds back to the Bitcoin
94    /// blockchain through a cooperative process with the Spark Service Provider (SSP). The SSP
95    /// facilitates the on-chain transaction and charges a service fee for this operation.
96    ///
97    /// # Arguments
98    ///
99    /// * `onchain_address` - The Bitcoin address where the funds should be sent
100    /// * `target_amount_sats` - Optional amount in satoshis to withdraw. If not specified,
101    ///                        attempts to withdraw all available funds in your wallet
102    ///
103    /// # Returns
104    ///
105    /// * `Ok(CoopExitResponse)` - Contains the cooperative exit request ID and exit transaction ID
106    /// * `Err(SparkSdkError)` - If the withdrawal request failed
107    ///
108    /// # Example
109    ///
110    /// ```
111    /// # use spark_rust::SparkSdk;
112    /// # use bitcoin::Address;
113    /// # use std::str::FromStr;
114    /// # async fn example(sdk: SparkSdk) -> Result<(), Box<dyn std::error::Error>> {
115    /// // Create a Bitcoin address to receive the funds
116    /// let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")?;
117    ///
118    /// // Withdraw all available funds
119    /// let response = sdk.withdraw(&address, None).await?;
120    /// println!("Withdrawal initiated with request ID: {:?}", response.request_id);
121    /// println!("Exit transaction ID: {}", response.exit_txid);
122    ///
123    /// // Or withdraw a specific amount (e.g., 50,000 satoshis)
124    /// // let response = sdk.withdraw(&address, Some(50_000)).await?;
125    /// # Ok(())
126    /// # }
127    /// ```
128    ///
129    /// # Note
130    ///
131    /// Withdrawals incur a service fee charged by the SSP. You can estimate this fee before
132    /// initiating a withdrawal using the `get_cooperative_exit_fee_estimate` method.
133    #[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
134    pub async fn withdraw(
135        &self,
136        onchain_address: &Address,
137        target_amount_sats: Option<u64>,
138    ) -> Result<CoopExitResponse, SparkSdkError> {
139        // Then perform the cooperative exit
140        with_handler_lock!(self, async {
141            self.cooperative_exit(onchain_address, target_amount_sats)
142                .await
143        })
144        .await
145    }
146
147    /// Performs a cooperative exit operation.
148    ///
149    /// This method allows a user to exit the Spark network cooperatively with the connector.
150    /// It signs refund transactions for the leaves being exited and sends them to the connector.
151    ///
152    /// # Arguments
153    ///
154    /// * `onchain_address` - The Bitcoin address to withdraw funds to
155    /// * `target_amount_sats` - Optional target amount in satoshis to withdraw
156    ///
157    /// # Returns
158    ///
159    /// * `Ok(String)` - The ID of the completed cooperative exit request
160    /// * `Err(SparkSdkError)` - If there was an error during the cooperative exit process
161    #[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
162    async fn cooperative_exit(
163        &self,
164        onchain_address: &Address,
165        target_amount_sats: Option<u64>,
166    ) -> Result<CoopExitResponse, SparkSdkError> {
167        if let Some(amount) = target_amount_sats {
168            if amount < DEFAULT_WITHDRAWAL_AMOUNT {
169                return Err(SparkSdkError::from(ValidationError::InvalidInput {
170                    field: "Target amount is less than the minimum withdrawal amount".to_string(),
171                }));
172            }
173        } else {
174            let leaves = self
175                .leaf_manager
176                .get_available_bitcoin_leaves(None, SparkNodeStatus::Available);
177
178            let amount = leaves
179                .iter()
180                .map(|leaf| leaf.get_tree_node().unwrap().value)
181                .sum::<u64>();
182
183            if amount < DEFAULT_WITHDRAWAL_AMOUNT {
184                return Err(SparkSdkError::from(ValidationError::InvalidInput {
185                    field: "Target amount is less than the minimum withdrawal amount".to_string(),
186                }));
187            }
188        }
189
190        let leaves_to_send = match target_amount_sats {
191            Some(amount) => {
192                // Get leaves for the target amount
193                self.prepare_leaves_for_amount(amount).await?.leaves
194            }
195            None => {
196                // Get all available BTC leaves
197                self.leaf_manager
198                    .get_available_bitcoin_leaves(None, SparkNodeStatus::CooperativeExit)
199            }
200        };
201
202        // return early if no leaves found
203        if leaves_to_send.is_empty() {
204            return Err(SparkSdkError::from(ValidationError::InvalidInput {
205                field: "No available leaves found".to_string(),
206            }));
207        }
208
209        // generate leaf key tweaks
210        let mut leaf_key_tweaks = Vec::new();
211        let network = self.config.spark_config.network.to_bitcoin_network();
212        for leaf in &leaves_to_send {
213            // generate signing public key from leaf ID
214            let old_signing_private_key = self.signer.expose_leaf_secret_key_for_transfer(
215                leaf.get_id().clone(),
216                SparkKeyType::BaseSigning,
217                0,
218                network,
219            )?;
220            // generate new signing public key
221            let new_signing_public_key = self.signer.new_ephemeral_keypair()?;
222
223            leaf_key_tweaks.push(LeafKeyTweak {
224                leaf: leaf.get_tree_node()?,
225                old_signing_private_key,
226                new_signing_public_key,
227            });
228        }
229
230        // initiate the coop exit with SSP
231        let leaf_ids = leaves_to_send
232            .iter()
233            .map(|leaf| leaf.get_id().clone())
234            .collect();
235
236        let initiate_response = self
237            .initiate_cooperative_exit_with_ssp(leaf_ids, onchain_address)
238            .await?;
239
240        // TODO: make a function
241        let connector_txid = initiate_response.connector_tx.compute_txid();
242        let coop_exit_txid = initiate_response.connector_tx.input[0].previous_output.txid;
243
244        // extract connector outputs
245        let mut connector_outputs = Vec::new();
246        // Only include outputs up to the second-to-last one (len - 1)
247        // This is because the last output is typically the change output
248        let output_count = initiate_response.connector_tx.output.len();
249        for i in 0..output_count.saturating_sub(1) {
250            connector_outputs.push(OutPoint {
251                txid: connector_txid,
252                vout: i as u32,
253            });
254        }
255
256        // get connector refund signatures
257        let coop_exit_txid_bytes = hex::decode(coop_exit_txid.to_string())
258            .map_err(|err| SparkSdkError::from(IoError::Decoding(err)))?;
259
260        let expiry = std::time::SystemTime::now()
261            .duration_since(std::time::UNIX_EPOCH)
262            .unwrap()
263            .as_secs()
264            + DEFAULT_COOPERATIVE_EXIT_EXPIRY;
265        let (transfer, _) = self
266            .get_connector_refund_signatures(
267                &leaf_key_tweaks,
268                &coop_exit_txid_bytes,
269                &connector_outputs,
270                &self.config.spark_config.ssp_identity_public_key,
271                expiry,
272            )
273            .await?;
274
275        // complete the cooperative exit using the internal handler
276        let complete_response = match self
277            .complete_cooperative_exit_with_ssp(transfer.id.clone(), initiate_response.request_id)
278            .await
279        {
280            Ok(response) => CoopExitResponse {
281                request_id: response,
282                exit_txid: coop_exit_txid,
283            },
284            Err(status) => {
285                // If the request fails, cancel the transfer and propagate the error
286                if let Err(cancel_err) = self.cancel_send_transfer(transfer.id.clone()).await {
287                    // Log the cancellation error but return the original error
288                    #[cfg(feature = "telemetry")]
289                    tracing::error!(
290                        "Failed to cancel transfer after complete_cooperative_exit error: {}",
291                        cancel_err
292                    );
293                }
294                return Err(status);
295            }
296        };
297
298        Ok(complete_response)
299    }
300
301    /// Creates a connector refund transaction.
302    ///
303    /// This method is equivalent to the TypeScript `createConnectorRefundTransaction` method.
304    /// It creates a refund transaction for a connector output.
305    ///
306    /// # Arguments
307    ///
308    /// * `sequence` - The sequence number for the transaction
309    /// * `node_outpoint` - The outpoint of the node transaction
310    /// * `connector_output` - The connector output
311    /// * `amount_sats` - The amount in satoshis
312    /// * `receiver_pubkey` - The receiver public key
313    ///
314    /// # Returns
315    ///
316    /// The created refund transaction
317    pub fn create_connector_refund_transaction(
318        &self,
319        sequence: u32,
320        node_outpoint: OutPoint,
321        connector_output: OutPoint,
322        amount_sats: u64,
323        receiver_pubkey: &PublicKey,
324    ) -> Transaction {
325        // Use the utility function from the codebase
326        create_connector_refund_tx(
327            sequence,
328            node_outpoint,
329            connector_output,
330            amount_sats,
331            receiver_pubkey,
332            self.config.spark_config.network.to_bitcoin_network(),
333        )
334    }
335}