spark_rust/wallet/handlers/
cooperative_exit.rs

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