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